diff options
Diffstat (limited to 'src/lib')
28 files changed, 2080 insertions, 1541 deletions
diff --git a/src/lib/address/components/CreateAddress.jsx b/src/lib/address/components/CreateAddress.jsx index 963a19aa..2879fb3d 100644 --- a/src/lib/address/components/CreateAddress.jsx +++ b/src/lib/address/components/CreateAddress.jsx @@ -48,19 +48,16 @@ const CreateAddress = () => { pinedMaps, setPinedMaps } = useMaps(); - useEffect(() => { - if (detailAddress) { - setValue('zip', detailAddress.postalCode); - const selectedState = states.find( - (state) => - detailAddress?.province.includes(state.label) || - state.label.includes(detailAddress?.province) - ); - setValue('state', selectedState?.value); - setValue('street', detailAddress?.street); - - } - }, [detailAddress, setValue]); + + const resetPin = useMaps((state) => state.resetPin); + const [showValidationPopup, setShowValidationPopup] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [selectedCityName, setSelectedCityName] = useState(""); + const [normalizedDistrict, setNormalizedDistrict] = useState(""); + + useEffect(() => { + resetPin(); + }, [resetPin]); useEffect(() => { const loadState = async () => { @@ -75,6 +72,20 @@ const CreateAddress = () => { setAddressMaps(''); }, []); + useEffect(() => { + if (detailAddress) { + setValue('zip', detailAddress.postalCode); + const selectedState = states.find( + (state) => + (detailAddress?.province || "").includes(state.label) || + state.label.includes(detailAddress?.province) + ); + setValue('state', selectedState?.value); + setValue('street', detailAddress?.street); + + } + }, [detailAddress, setValue]); + const watchState = watch('state'); useEffect(() => { setValue('city', ''); @@ -91,19 +102,6 @@ const CreateAddress = () => { } }, [watchState, setValue]); - useEffect(() => { - if (detailAddress && Object.keys(detailAddress).length > 0) { - const selectedCities = cities.find( - (city) => - city.label.toLowerCase() === detailAddress?.district.toLowerCase() - ) || cities.find( - (city) => - detailAddress?.district.toLowerCase().includes(city.label.toLowerCase()) || - city.label.toLowerCase().includes(detailAddress?.district.toLowerCase()) - ); - setValue('city', selectedCities?.value); - } - }, [cities, detailAddress, setValue]); useEffect(() => { if (addresses) { @@ -123,6 +121,29 @@ const CreateAddress = () => { } }, [auth]); + const normalizeName = (name = "") => { + return name + .toLowerCase() + .replace(/\bkabupaten\b/gi, "") + .replace(/\bkota\b/gi, "") + .trim(); + }; + + useEffect(() => { + if (detailAddress?.district) { + const normalizedDistrict = normalizeName(detailAddress.district); + + const selectedCity = cities.find((city) => { + const normalizedCity = normalizeName(city.label); + return normalizedCity === normalizedDistrict; + }); + + if (selectedCity) { + setValue("city", selectedCity.value); + } + } + }, [cities, detailAddress, setValue]); + const watchCity = watch('city'); useEffect(() => { setValue('district', ''); @@ -188,7 +209,23 @@ const CreateAddress = () => { } }, [subDistricts, detailAddress, setValue]); + + + // console.log(selectedCityName, '=', normalizedDistrict); const onSubmitHandler = async (values) => { + if (detailAddress) { + const cityName = normalizeName( + cities.find((c) => c.value === watch("city"))?.label || "" + ); + const districtName = normalizeName(detailAddress?.district || ""); + console.log(cityName, '=', districtName); + + if (cityName && cityName !== districtName) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + return; + } + } const data = { ...values, state_id: values.state, @@ -219,6 +256,24 @@ const CreateAddress = () => { <PinPointMap /> </div> </BottomPopup> + <BottomPopup + active={showValidationPopup} + close={() => setShowValidationPopup(false)} + > + <div className="leading-7 text-gray_r-12/80 text-center"> + {popupMessage} + </div> + + <div className="flex justify-center mt-6"> + <button + className="btn-solid-red w-full md:w-auto" + type="button" + onClick={() => setShowValidationPopup(false)} + > + OK + </button> + </div> + </BottomPopup> <div className='max-w-none md:container mx-auto flex p-0 md:py-10'> <div className='hidden md:block w-3/12 pr-4'> <Menu /> diff --git a/src/lib/address/components/EditAddress.jsx b/src/lib/address/components/EditAddress.jsx index deaa8a3e..75f1a89a 100644 --- a/src/lib/address/components/EditAddress.jsx +++ b/src/lib/address/components/EditAddress.jsx @@ -1,6 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import * as Yup from 'yup'; import cityApi from '../api/cityApi'; import { Controller, useForm } from 'react-hook-form'; @@ -44,22 +44,92 @@ const EditAddress = ({ id, defaultValues }) => { const [districts, setDistricts] = useState([]); const [subDistricts, setSubDistricts] = useState([]); const [tempAddress, setTempAddress] = useState(getValues('addressMap')); - const { addressMaps, + const resetPin = useMaps((state) => state.resetPin); + const setSelectedPosition = useMaps((state) => state.setSelectedPosition); + const setAddressMaps = useMaps((state) => state.setAddressMaps); + const setDetailAddress = useMaps((state) => state.setDetailAddress); + const [showValidationPopup, setShowValidationPopup] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [selectedCityName, setSelectedCityName] = useState(""); + const [normalizedDistrict, setNormalizedDistrict] = useState(""); + + const { + addressMaps, selectedPosition, detailAddress, pinedMaps, - setPinedMaps } = useMaps(); + setPinedMaps, + getDefaultCenter, // penting untuk deteksi default center + } = useMaps(); + + const normalizeName = (name = "") => { + return name + .toLowerCase() + .replace(/\bkabupaten\b/gi, "") + .replace(/\bkota\b/gi, "") + .trim(); + }; - + // Helper: cek apakah benar2 sudah PIN (bukan default center & ada addressMaps) + const isPinned = useMemo(() => { + if (!selectedPosition) return false; + + // pastikan selalu cast ke number + const lat = Number(selectedPosition.lat); + const lng = Number(selectedPosition.lng); + + // kalau hasil cast bukan angka valid + if (isNaN(lat) || isNaN(lng)) return false; + + const dc = + typeof getDefaultCenter === "function" + ? getDefaultCenter() + : { lat: -6.2, lng: 106.816666 }; + + const nearDefault = + Math.abs(lat - dc.lat) < 1e-4 && + Math.abs(lng - dc.lng) < 1e-4; + + return Boolean(addressMaps) && !nearDefault; + }, [selectedPosition, addressMaps, getDefaultCenter]); + + // Hanya isi addressMap & lat/lng di form kalau SUDAH PIN useEffect(() => { - if (addressMaps) { - setTempAddress(addressMaps); - setValue('addressMap', addressMaps); - setValue('longtitude', selectedPosition.lng); - setValue('latitude', selectedPosition.lat); + // cek kalau form punya koordinat lama + const lat = getValues("latitude"); + const lng = getValues("longtitude"); + const oldAddress = getValues("addressMap"); + + if (lat && lng) { + setTempAddress(oldAddress); + setValue("addressMap", oldAddress); + + // kalau store punya setter untuk koordinat/alamat: + if (typeof setSelectedPosition === "function") { + setSelectedPosition({ lat: Number(lat), lng: Number(lng) }); + } + if (typeof setAddressMaps === "function") { + setAddressMaps(oldAddress); + } } - }, [addressMaps, selectedPosition, setValue]); - + }, [setSelectedPosition, setAddressMaps, getValues, setValue]); + + useEffect(() => { + const addr = getValues("addressMap"); + + if (!addr || addr.trim() === "") { + resetPin(); + } else { + setAddressMaps(addr); + + const lat = getValues("latitude"); + const lng = getValues("longtitude"); + if (lat && lng) { + setSelectedPosition({ lat: Number(lat), lng: Number(lng) }); + } + } + }, [getValues, resetPin, setAddressMaps, setSelectedPosition]); + useEffect(() => { if (Object.keys(detailAddress).length > 0) { setValue('zip', detailAddress.postalCode); @@ -72,7 +142,7 @@ const EditAddress = ({ id, defaultValues }) => { setValue('street', detailAddress?.street); } }, [detailAddress, setValue]); - + useEffect(() => { const loadProfile = async () => { const dataProfile = await addressApi({ id: auth.partnerId }); @@ -83,13 +153,40 @@ const EditAddress = ({ id, defaultValues }) => { setValue('alamat_wajib_pajak', dataProfile.alamatWajibPajak); setValue('alamat_bisnis', dataProfile.alamatBisnis); setValue('business_name', dataProfile.name); - setValue('city', dataProfile.city?.id); - setValue('district', dataProfile.district?.id); - setValue('subDistrict', dataProfile.subDistrict?.id); }; if (auth) loadProfile(); - }, [auth?.parentId]); - + }, [auth?.parentId, setValue]); + + // Isi ZIP/Prov dari detailAddress (JANGAN isi street) +useEffect(() => { + const zip = getValues("zip"); + const province = getValues("state"); + const street = getValues("street"); + + // set zip dari DB kalau kosong + if (!zip && defaultValues?.zip) { + setValue("zip", defaultValues.zip); + } + + // set state dari DB kalau kosong + if (!province && defaultValues?.state) { + const selectedState = states.find( + (state) => + defaultValues.state.includes(state.label) || + state.label.includes(defaultValues.state) + ); + if (selectedState) { + setValue("state", selectedState.value); + } + } + + // set street dari DB kalau kosong + if (!street && defaultValues?.street) { + setValue("street", defaultValues.street); + } + }, [states, setValue, getValues, defaultValues]); + + useEffect(() => { const loadStates = async () => { let dataStates = await stateApi({ tempo: false }); @@ -101,7 +198,7 @@ const EditAddress = ({ id, defaultValues }) => { }; loadStates(); }, []); - + const watchState = watch('state'); useEffect(() => { setValue('city', ''); @@ -122,35 +219,46 @@ const EditAddress = ({ id, defaultValues }) => { loadCities(); } }, [watchState, setValue, getValues]); - + useEffect(() => { - if (Object.keys(detailAddress).length > 0) { - const selectedCities = cities.find( - (city) => - city.label.toLowerCase() === detailAddress?.district.toLowerCase() - ) || cities.find( + if (!isPinned) return; + + if (getValues("city")) return; + + if (Object.keys(detailAddress || {}).length > 0) { + const selectedCities = + cities.find( + (city) => + city.label.toLowerCase() === detailAddress?.district?.toLowerCase() + ) || + cities.find( (city) => - detailAddress?.district.toLowerCase().includes(city.label.toLowerCase()) || - city.label.toLowerCase().includes(detailAddress?.district.toLowerCase()) + detailAddress?.district + ?.toLowerCase() + .includes(city.label.toLowerCase()) || + city.label + .toLowerCase() + .includes(detailAddress?.district?.toLowerCase()) ); - setValue('city', selectedCities?.value); - } - }, [cities, detailAddress, setValue]); - - const watchCity = watch('city'); - useEffect(() => { - if (watchCity) { - // setValue('district', ''); - const loadDistricts = async () => { - let dataDistricts = await districtApi({ cityId: watchCity }); - dataDistricts = dataDistricts.map((district) => ({ - value: district.id, - label: district.name, + + if (selectedCities) { + setValue("city", selectedCities.value); + } + } + }, [cities, detailAddress, isPinned, getValues, setValue]); + + const watchCity = watch('city'); + useEffect(() => { + if (watchCity) { + const loadDistricts = async () => { + let dataDistricts = await districtApi({ cityId: watchCity }); + dataDistricts = dataDistricts.map((district) => ({ + value: district.id, + label: district.name, })); setDistricts(dataDistricts); let oldDistrict = getValues('oldDistrict'); if (oldDistrict) { - // setValue('district', oldDistrict); setValue('oldDistrict', ''); } }; @@ -159,6 +267,9 @@ const EditAddress = ({ id, defaultValues }) => { }, [watchCity, setValue, getValues]); useEffect(() => { + if (!isPinned) return; // skip kalau belum pin + if (getValues("district")) return; + if (Object.keys(detailAddress).length > 0) { const selectedDistrict = districts.find( (district) => @@ -173,12 +284,10 @@ const EditAddress = ({ id, defaultValues }) => { } }, [districts, detailAddress, setValue]); - - + const watchDistrict = watch('district'); useEffect(() => { if (watchDistrict) { - // setValue('subDistrict', ''); const loadSubDistricts = async () => { let dataSubDistricts = await subDistrictApi({ districtId: watchDistrict, @@ -199,8 +308,10 @@ const EditAddress = ({ id, defaultValues }) => { } }, [watchDistrict, setValue, getValues]); - useEffect(() => { + if (!isPinned) return; // skip kalau belum pin + if (getValues("subDistrict")) return; + if (Object.keys(detailAddress).length > 0) { const selectedSubDistrict = subDistricts.find( (district) => @@ -221,8 +332,39 @@ const EditAddress = ({ id, defaultValues }) => { setValue('id', id); } }, [id, setValue]); - + const onSubmitHandler = async (values) => { + if (addressMaps) { + if (!detailAddress){ + if (defaultValues?.oldCity !== values.city) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + // console.log(detailAddress) + return; + } + } + if (detailAddress) { + const cityName = normalizeName( + cities.find((c) => c.value === watch("city"))?.label || "" + ); + const districtName = normalizeName(detailAddress?.district || ""); + // console.log(cityName, '=', districtName); + + if (cityName && cityName !== districtName) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + return; + } + } + } + // if(!addressMaps && detailAddress){ + // if (selectedCityName && selectedCityName !== detailAddress?.district?.toLowerCase()) { + // setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih 3"); + // setShowValidationPopup(true); + // return; + // } + // } + const data = { ...values, phone: values.mobile, @@ -230,20 +372,32 @@ const EditAddress = ({ id, defaultValues }) => { city_id: parseInt(values.city, 10), district_id: parseInt(values.district, 10), sub_district_id: parseInt(values.subDistrict, 10), - longtitude: selectedPosition?.lng, - latitude: selectedPosition?.lat, - address_map: addressMaps, }; + + + if (isPinned) { + data.longtitude = selectedPosition?.lng; + data.latitude = selectedPosition?.lat; + data.address_map = addressMaps || values.addressMap; + data.use_pin = true; + } else { + data.use_pin = false; + // pastikan tidak ada nilai default center yang ikut terkirim + delete data.longtitude; + delete data.latitude; + delete data.address_map; + } + if (!auth.company) { data.alamat_lengkap_text = values.street; } + try { const address = await editAddressApi({ id, data }); console.log('Response address:', address); let isUpdated = null; - // Jika company dan partnerId sama dengan id, maka update data alamat wajib pajak const isCompanyEditingSelf = auth.company && auth.partnerId == id; if (isCompanyEditingSelf) { @@ -260,15 +414,13 @@ const EditAddress = ({ id, defaultValues }) => { mobile: values.mobile, }; - const isUpdated = await editPartnerApi({ + const isUpdatedRes = await editPartnerApi({ id: auth.partnerId, data: dataAlamat, }); - - console.log('Response isUpdated:', isUpdated); + console.log('Response isUpdated:', isUpdatedRes); } - // Validasi kondisi sukses const isSuccess = !!address?.id; if (isSuccess) { @@ -286,21 +438,10 @@ const EditAddress = ({ id, defaultValues }) => { toast.error(error?.message || 'Terjadi kesalahan tidak terduga.'); } - const dataProfile = await addressApi({ id: auth.partnerId }); - console.log('ini adalah', dataProfile); - - - // if (isUpdated?.id) { - // if (address?.id && auth.company ? isUpdated?.id : true) { - // toast.success('Berhasil mengubah alamat'); - // router.back(); - // } else { - // toast.error('Terjadi kesalahan internal'); - // router.back(); - // } + // console.log('ini adalah', dataProfile); }; - + // console.log('ini adalah', detailAddress); return ( <> <BottomPopup @@ -310,12 +451,29 @@ const EditAddress = ({ id, defaultValues }) => { close={() => setPinedMaps(false)} > <div className='flex mt-4'> - <PinPointMap - initialLatitude={selectedPosition?.lat} - initialLongitude={selectedPosition?.lng} - initialAddress={tempAddress} - /> + <PinPointMap + initialLatitude={getValues('latitude')} + initialLongitude={getValues('longtitude')} + initialAddress={getValues('addressMap')} + /> + </div> + </BottomPopup> + <BottomPopup + active={showValidationPopup} + close={() => setShowValidationPopup(false)} + > + <div className="leading-7 text-gray_r-12/80 text-center"> + {popupMessage} + </div> + <div className="flex justify-center mt-6"> + <button + className="btn-solid-red w-full md:w-auto" + type="button" + onClick={() => setShowValidationPopup(false)} + > + OK + </button> </div> </BottomPopup> <div className='max-w-none md:container mx-auto flex p-0 md:py-10'> @@ -332,17 +490,30 @@ const EditAddress = ({ id, defaultValues }) => { <form onSubmit={handleSubmit(onSubmitHandler)}> <div className='mb-4 items-start'> <label className='form-label mb-2'>Koordinat Alamat</label> - {tempAddress ? ( + {addressMaps ? ( <div className='flex gap-x-2 items-center'> - <button type='button' className="flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition"> - <MapPinIcon class='h-6 w-6' onClick={() => setPinedMaps(true)} />{' '} - </button> - <span> {tempAddress} </span> + <button + type='button' + className='flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition' + > + <MapPinIcon + className='h-6 w-6' + onClick={() => setPinedMaps(true)} + /> + </button> + <span> {addressMaps} </span> </div> ) : ( - <Button variant='plain' style={{ padding: 0 }} onClick={() => setPinedMaps(true)}> - <button type='button' className="flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition"> - <MapPinIcon className="h-6 w-6" /> + <Button + variant='plain' + style={{ padding: 0 }} + onClick={() => setPinedMaps(true)} + > + <button + type='button' + className='flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition' + > + <MapPinIcon className='h-6 w-6' /> </button> Pin Koordinat Alamat </Button> @@ -530,4 +701,4 @@ const types = [ { value: 'other', label: 'Other Address' }, ]; -export default EditAddress; +export default EditAddress;
\ No newline at end of file diff --git a/src/lib/auth/api/checkParentStatusApi.js b/src/lib/auth/api/checkParentStatusApi.js new file mode 100644 index 00000000..aa2eb1b6 --- /dev/null +++ b/src/lib/auth/api/checkParentStatusApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi'; +import { getAuth } from '@/core/utils/auth'; + +const checkParentStatusApi = async () => { + const auth = getAuth(); + const checkParentStatus = await odooApi( + 'GET', + `/api/v1/user/${auth.partnerId}/parent_status` + ); + return checkParentStatus; +}; + +export default checkParentStatusApi;
\ No newline at end of file diff --git a/src/lib/auth/api/editPersonalProfileApi.js b/src/lib/auth/api/editPersonalProfileApi.js index 39cd44c1..90bda114 100644 --- a/src/lib/auth/api/editPersonalProfileApi.js +++ b/src/lib/auth/api/editPersonalProfileApi.js @@ -3,7 +3,7 @@ import { getAuth } from '@/core/utils/auth' const editPersonalProfileApi = async ({ data }) => { const auth = getAuth() - const dataProfile = await odooApi('PUT', `/api/v1/user/${auth.id}`, data) + const dataProfile = await odooApi('POST', `/api/v1/user/${auth.id}`, data) return dataProfile } diff --git a/src/lib/auth/api/switchAccountApi.js b/src/lib/auth/api/switchAccountApi.js index 79ca2553..f62693f6 100644 --- a/src/lib/auth/api/switchAccountApi.js +++ b/src/lib/auth/api/switchAccountApi.js @@ -4,7 +4,7 @@ import { getAuth } from '@/core/utils/auth'; const switchAccountApi = async ({ data }) => { const auth = getAuth(); const switchAccount = await odooApi( - 'PUT', + 'POST', `/api/v1/user/${auth.partnerId}/switch`, data ); diff --git a/src/lib/auth/components/SwitchAccount.jsx b/src/lib/auth/components/SwitchAccount.jsx index 46e57348..840758c9 100644 --- a/src/lib/auth/components/SwitchAccount.jsx +++ b/src/lib/auth/components/SwitchAccount.jsx @@ -13,9 +13,10 @@ import { useRegisterStore } from '~/modules/register/stores/useRegisterStore.ts' import { registerUser } from '~/services/auth'; import { useMutation } from 'react-query'; import { isValid } from 'zod'; +import Spinner from "@/core/components/elements/Spinner/LogoSpinner"; import useDevice from '@/core/hooks/useDevice'; import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; -const SwitchAccount = ({ company_type }) => { +const SwitchAccount = ({ company_type, setIsAprove, setUbahAkun }) => { const { isDesktop, isMobile } = useDevice(); const auth = useAuth(); const [isOpen, setIsOpen] = useState(true); @@ -27,6 +28,8 @@ const SwitchAccount = ({ company_type }) => { const [selectedValue, setSelectedValue] = useState('PKP'); const [buttonSubmitClick, setButtonSubmitClick] = useState(false); const [changeConfirmation, setChangeConfirmation] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingPopup, setIsLoadingPopup] = useState(false); const { register, setValue, handleSubmit } = useForm({ defaultValues: { email: '', @@ -131,35 +134,46 @@ const SwitchAccount = ({ company_type }) => { setIsPKP(false); } }; - const onSubmitHandler = async (values) => { - toast.loading('Mengubah status akun...'); + const onSubmitHandler = async () => { + setChangeConfirmation(false); // Tutup popup konfirmasi + setIsLoadingPopup(true); // Munculkan popup loading + updateForm('parent_id', `${auth.parentId}`); - setChangeConfirmation(false); - // let data = { ...form, id: `${auth.partnerId}` }; + const data = form; if (!isFormValid) { setNotValid(true); setButtonSubmitClick(!buttonSubmitClick); + toast.error('Form belum valid. Mohon periksa kembali input Anda.'); + setIsLoadingPopup(false); return; - } else { - setButtonSubmitClick(!buttonSubmitClick); - setNotValid(false); } - // if (!values.password) delete data.password; - const isUpdated = await switchAccountApi({ data }); - if (isUpdated?.switch === 'Pending') { - // setAuth(isUpdated.user); - // setValue('password', ''); - toast.success('Berhasil mengubah akun', { duration: 1500 }); - setTimeout(() => { - window.location.reload(); - }, 1500); - return; + try { + const isUpdated = await switchAccountApi({ data }); + + if (isUpdated?.switch === 'pending') { + toast.success('Berhasil mengajukan ubah akun', { duration: 1500 }); + if (typeof window !== 'undefined') { + localStorage.setItem('autoCheckProfile', 'true'); + } + setTimeout(() => { + setIsAprove('pending'); + setUbahAkun('pending'); + window.location.reload(); + }, 1500); + } else { + toast.error('Gagal mengubah akun. Silakan coba lagi nanti atau hubungi admin jika masalah tetap terjadi.'); + setIsLoadingPopup(false); + } + } catch (error) { + console.error(error); + toast.error('Terjadi kesalahan saat menghubungi server, silahkan cek internet Anda atau hubungi admin Indoteknik.'); + setIsLoadingPopup(false); } - toast.error('Terjadi kesalahan internal'); }; + const onSubmitHandlerCancel = async (values) => { window.location.reload(); }; @@ -172,26 +186,37 @@ const SwitchAccount = ({ company_type }) => { title='Ubah profil Bisnis' > <div className='leading-7 text-gray_r-12/80'> - Anda yakin akan merubah profil bisnis anda dari INDIVIDU menjadi{' '} - {selectedValue}? + Anda yakin akan merubah profil bisnis anda dari INDIVIDU menjadi {selectedValue}? </div> <div className='flex mt-6 gap-x-4 md:justify-end'> <button - className='btn-solid-red flex-1 md:flex-none' + className='btn-solid-red flex-1 md:flex-none flex items-center justify-center' type='button' onClick={onSubmitHandler} + disabled={isLoading} > - Ya, Ubah + {isLoading && <Spinner className="w-4 h-4 mr-2 text-white" />} + {isLoading ? 'Menyimpan...' : 'Ya, Ubah'} </button> <button className='btn-light flex-1 md:flex-none' type='button' onClick={() => setChangeConfirmation(false)} + disabled={isLoading} > Batal </button> </div> </BottomPopup> + <BottomPopup active={isLoadingPopup} close=""> + <div className="leading-7 text-gray_r-12/80 flex justify-center"> + Mengubah status akun... + </div> + <div className="container flex justify-center my-4"> + <Spinner width={48} height={48} /> + </div> + </BottomPopup> + {/* <div type='button' className='ml-4 flex items-center text-left w-full'> <div className={`flex ${ diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx index 127904ee..50557c3e 100644 --- a/src/lib/category/components/Breadcrumb.jsx +++ b/src/lib/category/components/Breadcrumb.jsx @@ -1,56 +1,188 @@ -import odooApi from '@/core/api/odooApi' -import { createSlug } from '@/core/utils/slug' +import odooApi from '@/core/api/odooApi'; +import { createSlug } from '@/core/utils/slug'; import { Breadcrumb as ChakraBreadcrumb, BreadcrumbItem, BreadcrumbLink, - Skeleton -} from '@chakra-ui/react' -import Link from 'next/link' -import React from 'react' -import { useQuery } from 'react-query' - -/** - * Render a breadcrumb component. - * - * @param {object} categoryId - The ID of the category. - * @return {JSX.Element} The breadcrumb component. - */ + Skeleton, +} from '@chakra-ui/react'; +import Link from 'next/link'; +import React from 'react'; +import { useQuery } from 'react-query'; +import useDevice from '@/core/hooks/useDevice'; + const Breadcrumb = ({ categoryId }) => { const breadcrumbs = useQuery( - `category-breadcrumbs/${categoryId}`, - async () => await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) - ) - - return ( - <div className='container mx-auto py-4 md:py-6'> - <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-2/3'> - <ChakraBreadcrumb> - <BreadcrumbItem> - <BreadcrumbLink as={Link} href='/' className='!text-danger-500 whitespace-nowrap'> - Home - </BreadcrumbLink> - </BreadcrumbItem> - - {breadcrumbs.data?.map((category, index) => ( - <BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.data.length - 1}> - {index === breadcrumbs.data.length - 1 ? ( - <BreadcrumbLink className='whitespace-nowrap'>{category.name}</BreadcrumbLink> - ) : ( + ['category-breadcrumbs', categoryId], + async () => + await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) + ); + const { isDesktop, isMobile } = useDevice(); + + const items = breadcrumbs.data ?? []; + const lastIdx = items.length - 1; + + if (isDesktop) { + return ( + <div className='container mx-auto py-4 md:py-6'> + <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-2/3'> + <ChakraBreadcrumb + spacing='8px' + sx={{ + '& ol': { + display: 'flex', + flexWrap: { base: 'wrap', md: 'nowrap' }, + alignItems: 'center', + }, + '& li': { display: 'inline-flex', alignItems: 'center' }, + '& li:not(:last-of-type)': { + flex: '0 0 auto', + whiteSpace: 'nowrap', + }, + '& li:last-of-type': { + flex: '1 1 auto', + minWidth: 0, + }, + }} + > + {/* Home */} + <BreadcrumbItem> + <BreadcrumbLink as={Link} href='/' className='!text-danger-500'> + Home + </BreadcrumbLink> + </BreadcrumbItem> + + {/* Categories */} + {items.map((category, index) => { + const isLast = index === lastIdx; + return ( + <BreadcrumbItem key={index} isCurrentPage={isLast}> + {isLast ? ( + <BreadcrumbLink className='block whitespace-normal break-words md:whitespace-nowrap'> + {category.name} + </BreadcrumbLink> + ) : ( + <BreadcrumbLink + as={Link} + href={createSlug( + '/shop/category/', + category.name, + category.id + )} + className='!text-danger-500' + > + {category.name} + </BreadcrumbLink> + )} + </BreadcrumbItem> + ); + })} + </ChakraBreadcrumb> + </Skeleton> + </div> + ); + } + + if (isMobile) { + const items = breadcrumbs.data ?? []; + const n = items.length; + const lastCat = n >= 1 ? items[n - 1] : null; // terakhir (current) + const secondLast = n >= 2 ? items[n - 2] : null; // sebelum current + const beforeSecond = n >= 3 ? items[n - 3] : null; // sebelum secondLast + const hiddenText = + n >= 3 + ? items + .slice(0, n - 2) + .map((c) => c.name) + .join(' / ') + : ''; + + return ( + <div className='container mx-auto py-2 mt-2'> + <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-full'> + <ChakraBreadcrumb + separator={<span className='mx-1'>/</span>} // lebih rapat + spacing='4px' + sx={{ + '& ol': { + display: 'flex', + alignItems: 'center', + overflow: 'hidden', // untuk ellipsis + whiteSpace: 'nowrap', // untuk ellipsis + gap: '0', // no extra gap + }, + '& li': { display: 'inline-flex', alignItems: 'center' }, + '& li:not(:last-of-type)': { + flex: '0 0 auto', + whiteSpace: 'nowrap', + }, + '& li:last-of-type': { + flex: '0 1 auto', // jangan ambil full space biar gak keliatan “space kosong” + minWidth: 0, + }, + }} + className='text-caption-2 p-0' + > + {/* Home */} + <BreadcrumbItem> + <BreadcrumbLink as={Link} href='/' className='!text-danger-500'> + Home + </BreadcrumbLink> + </BreadcrumbItem> + + {/* Jika ada kategori sebelum secondLast, tampilkan '..' (link ke beforeSecond) */} + {beforeSecond && ( + <BreadcrumbItem> <BreadcrumbLink as={Link} - href={createSlug('/shop/category/', category.name, category.id)} - className='!text-danger-500 whitespace-nowrap' + href={createSlug( + '/shop/category/', + beforeSecond.name, + beforeSecond.id + )} + title={hiddenText} + aria-label={`Kembali ke ${beforeSecond.name}`} + className='!text-danger-500' > - {category.name} + .. </BreadcrumbLink> - )} - </BreadcrumbItem> - ))} - </ChakraBreadcrumb> - </Skeleton> - </div> - ) -} - -export default Breadcrumb + </BreadcrumbItem> + )} + + {/* secondLast sebagai link (kalau ada) */} + {secondLast && ( + <BreadcrumbItem> + <BreadcrumbLink + as={Link} + href={createSlug( + '/shop/category/', + secondLast.name, + secondLast.id + )} + className='!text-danger-500' + > + {secondLast.name} + </BreadcrumbLink> + </BreadcrumbItem> + )} + + {/* lastCat (current) dengan truncate & lebar dibatasi */} + {lastCat && ( + <BreadcrumbItem isCurrentPage> + <span + className='inline-block truncate align-bottom' + style={{ maxWidth: '60vw' }} // batasi lebar supaya gak “makan” baris & keliatan space kosong + title={lastCat.name} + > + {lastCat.name} + </span> + </BreadcrumbItem> + )} + </ChakraBreadcrumb> + </Skeleton> + </div> + ); + } +}; + +export default Breadcrumb; diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js index c30d9631..11c7d4c2 100644 --- a/src/lib/checkout/api/checkoutApi.js +++ b/src/lib/checkout/api/checkoutApi.js @@ -11,6 +11,16 @@ export const checkoutApi = async ({ data }) => { return dataCheckout; }; +export const checkoutQuotation = async (data) => { + const auth = getAuth(); + const qs = new URLSearchParams({ context: 'quotation' }).toString(); + return odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/checkout?${qs}`, + data + ); +}; + export const getProductsCheckout = async (query) => { const queryParam = new URLSearchParams(query); const userId = getAuth()?.id; diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx index d8ede118..6cda069c 100644 --- a/src/lib/checkout/components/Checkout.jsx +++ b/src/lib/checkout/components/Checkout.jsx @@ -55,7 +55,9 @@ function convertToInternational(number) { } const Checkout = () => { - const PPN = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; + const PPN = process.env.NEXT_PUBLIC_PPN + ? parseFloat(process.env.NEXT_PUBLIC_PPN) + : 0; const router = useRouter(); const query = router.query.source ?? null; const qVoucher = router.query.voucher ?? null; @@ -172,7 +174,7 @@ const Checkout = () => { selectedService, listExpedisi, setExpedisi, - productSla + productSla, } = useCheckout(); const expedisiValidation = useRef(null); @@ -184,7 +186,7 @@ const Checkout = () => { let dataVoucher = await getVoucher(auth?.id, { source: query, type: 'all,brand', - partner_id : auth?.partnerId, + partner_id: auth?.partnerId, }); SetListVoucher(dataVoucher); @@ -210,7 +212,6 @@ const Checkout = () => { return; } - dataVoucher.forEach((addNewLine) => { if (addNewLine.applyType !== 'shipping') { // Mencari voucher dalam listVouchers @@ -374,17 +375,21 @@ const Checkout = () => { } return; } - if (selectedCourierId !== SELF_PICKUP_ID) { // Menggunakan selectedCourierId karena lebih spesifik dan numerik - if (!selectedService) { // Jika kurir bukan Self Pickup, maka harus ada layanan yang dipilih - toast.error('Harap pilih tipe layanan pengiriman'); - return; - } - // Validasi biaya kirim hanya untuk kurir selain Self Pickup (dan ID kurir 1 jika itu kasus khusus) - // Jika selectedCourierId adalah 1 (misalnya kurir internal yang bisa gratis), lewati validasi biayaKirim 0 - if (selectedCourierId !== 1 && biayaKirim === 0) { - toast.error('Maaf, layanan tidak tersedia untuk ekspedisi ini. Mohon pilih ekspedisi lain atau layanan lain.'); - return; - } + if (selectedCourierId !== SELF_PICKUP_ID) { + // Menggunakan selectedCourierId karena lebih spesifik dan numerik + if (!selectedService) { + // Jika kurir bukan Self Pickup, maka harus ada layanan yang dipilih + toast.error('Harap pilih tipe layanan pengiriman'); + return; + } + // Validasi biaya kirim hanya untuk kurir selain Self Pickup (dan ID kurir 1 jika itu kasus khusus) + // Jika selectedCourierId adalah 1 (misalnya kurir internal yang bisa gratis), lewati validasi biayaKirim 0 + if (selectedCourierId !== 1 && biayaKirim === 0) { + toast.error( + 'Maaf, layanan tidak tersedia untuk ekspedisi ini. Mohon pilih ekspedisi lain atau layanan lain.' + ); + return; + } } setIsLoading(true); const productOrder = products.map((product) => ({ @@ -415,7 +420,8 @@ const Checkout = () => { order_line: JSON.stringify(productOrder), delivery_amount: biayaKirim, carrier_id: selectedCourierId, - estimated_arrival_days_start : parseInt(eta_courier_start) + parseInt(productSla), + estimated_arrival_days_start: + parseInt(eta_courier_start) + parseInt(productSla), estimated_arrival_days: parseInt(eta_courier) + parseInt(productSla), delivery_service_type: selectedService?.service_type, flash_sale: hasFlashSale, // dibuat negasi untuk ngetest kebalikan nilai false @@ -430,8 +436,8 @@ const Checkout = () => { if (typeof file == 'undefined') { toast.error( 'Nomor PO ' + - poNumber.current.value + - ' telah dimasukkan, Harap upload file PO yang dimaksud' + poNumber.current.value + + ' telah dimasukkan, Harap upload file PO yang dimaksud' ); setIsLoading(false); return; @@ -1019,10 +1025,10 @@ const Checkout = () => { </div> )} - {selectedCarrierId == SELF_PICKUP_ID && ( + {selectedCourierId == SELF_PICKUP_ID && ( <PickupAddress label='Alamat Pickup' /> )} - {selectedCarrierId != SELF_PICKUP_ID && ( + {selectedCourierId != SELF_PICKUP_ID && ( <Skeleton isLoaded={!!selectedAddress.invoicing && !!selectedAddress.shipping} minHeight={320} @@ -1137,7 +1143,9 @@ const Checkout = () => { <div>{currencyFormat(cartCheckout?.subtotal)}</div> </div> <div className='flex gap-x-2 justify-between'> - <div className='text-gray_r-11'>PPN {((PPN - 1) * 100).toFixed(0)}%</div> + <div className='text-gray_r-11'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </div> <div>{currencyFormat(cartCheckout?.tax)}</div> </div> <div className='flex gap-x-2 justify-between'> @@ -1269,10 +1277,7 @@ const Checkout = () => { className='flex-1 btn-yellow' onClick={checkout} disabled={ - !products || - products?.length == 0 || - priceCheck || - hasNoPrice + !products || products?.length == 0 || priceCheck || hasNoPrice || isLoading } > {isLoading ? 'Loading...' : 'Lanjut Pembayaran'} @@ -1317,16 +1322,11 @@ const Checkout = () => { <div className='flex'> {' '} <div className='w-3/4 border border-gray_r-6 rounded bg-white'> - {selectedCarrierId == SELF_PICKUP_ID && ( + {selectedCourierId == SELF_PICKUP_ID && ( <PickupAddress label='Alamat Pickup' /> )} - {selectedCarrierId != SELF_PICKUP_ID && ( - <Skeleton - isLoaded={ - !!selectedAddress.invoicing && !!selectedAddress.shipping - } - minHeight={290} - > + {selectedCourierId != SELF_PICKUP_ID && ( + <Skeleton isLoaded minHeight={290}> <SectionAddress address={selectedAddress.shipping} label='Alamat Pengiriman' @@ -1444,7 +1444,9 @@ const Checkout = () => { <div>{currencyFormat(cartCheckout?.subtotal)}</div> </div> <div className='flex gap-x-2 justify-between'> - <div className='text-gray_r-11'>PPN {((PPN - 1) * 100).toFixed(0)}%</div> + <div className='text-gray_r-11'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </div> <div>{currencyFormat(cartCheckout?.tax)}</div> </div> <div className='flex gap-x-2 justify-between'> @@ -1577,7 +1579,8 @@ const Checkout = () => { !products || products?.length == 0 || priceCheck || - hasNoPrice + hasNoPrice || + isLoading } > {isLoading ? 'Loading...' : 'Lanjut Pembayaran'} @@ -1633,8 +1636,14 @@ const SectionAddress = ({ address, label, url }) => ( {address.addressMap ? ( <label>Sudah Pinpoint</label> ) : ( - <Link href={'/my/address/' + address.id + '/edit'} target='_blank' className='cursor-pointer'> - <label className='text-red-500 cursor-pointer '>Belum Pinpoint</label> + <Link + href={'/my/address/' + address.id + '/edit'} + target='_blank' + className='cursor-pointer' + > + <label className='text-red-500 cursor-pointer '> + Belum Pinpoint + </label> </Link> )} </div> @@ -1644,7 +1653,7 @@ const SectionAddress = ({ address, label, url }) => ( ); const SectionValidation = ({ address }) => - address?.stateId == 0 && ( + address?.stateId === 0 && ( <BottomPopup active={true} title='Update Alamat'> <div className='leading-7 text-gray_r-12/80'> Mohon untuk memperbarui alamat Anda dengan mengklik tombol di bawah ini.{' '} @@ -1661,14 +1670,14 @@ const SectionValidation = ({ address }) => ); const SectionExpedisi = ({ - address, - listExpedisi, - setSelectedExpedisi, - checkWeigth, - checkoutValidation, - expedisiValidation, - loadingRajaOngkir, - }) => + address, + listExpedisi, + setSelectedExpedisi, + checkWeigth, + checkoutValidation, + expedisiValidation, + loadingRajaOngkir, +}) => address?.rajaongkirCityId > 0 && ( <div className='p-4' ref={expedisiValidation}> <div className='flex justify-between items-center'> @@ -1720,9 +1729,9 @@ const SectionExpedisi = ({ )} </div> <style jsx>{` - .shake { - animation: shake 0.4s ease-in-out; - } + .shake { + animation: shake 0.4s ease-in-out; + } `}</style> </div> {checkWeigth == true && ( diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx index d533325e..51837a59 100644 --- a/src/lib/checkout/components/FinishCheckout.jsx +++ b/src/lib/checkout/components/FinishCheckout.jsx @@ -55,12 +55,21 @@ const FinishCheckout = ({ query }) => { <div className='text-title-sm md:text-title-lg text-center font-semibold'> Terima Kasih atas Pembelian di Indoteknik.com </div> - <p className='text-title-sm md:text-title-lg font-semibold my-2'>No. Transaksi: <span className='text-red-500'>{query?.order_id?.replaceAll('-', '/')}</span></p> + <p className='text-title-sm md:text-title-lg font-semibold my-2'> + No. Transaksi:{' '} + <span className='text-red-500'> + {query?.order_id?.replaceAll('-', '/')} + </span> + </p> <div className='flex flex-col justify-center items-center text-body-2 md:text-body-1 text-center mt-3 px-24 md:px-36 py-4 border-2 gap-y-2 rounded'> - <p className="text-title-sm md:text-title-xl text-gray-500 mt-1">Estimasi Barang Siap pada Tanggal</p> - <p className="text-title-sm md:text-title-xl text-red-500 font-semibold my-2">{data?.expectedReadyToShip}</p> + <p className='text-title-sm md:text-title-xl text-gray-500 mt-1'> + Estimasi Barang Siap pada Tanggal + </p> + <p className='text-title-sm md:text-title-xl text-red-500 font-semibold my-2'> + {data?.expectedReadyToShip} + </p> <Link - href={`/my/quotations/${data?.id}`} + href={`/my/transactions/${data?.id}`} className='btn-solid-red rounded-md text-base' > Cek Detail Transaksi @@ -74,7 +83,7 @@ const FinishCheckout = ({ query }) => { </a>{' '} atau{' '} <span onClick={sendEmail} className='text-red-500 cursor-pointer'> - kirim rincian pesanan ulang + Kirim ulang rincian pesanan ke Email anda. </span> . </div> diff --git a/src/lib/checkout/components/SectionExpedition.jsx b/src/lib/checkout/components/SectionExpedition.jsx index 2e92ffbc..66182589 100644 --- a/src/lib/checkout/components/SectionExpedition.jsx +++ b/src/lib/checkout/components/SectionExpedition.jsx @@ -18,7 +18,7 @@ import { getProductsSla } from '../api/checkoutApi'; function mappingItems(products) { return products?.map((item) => ({ // name: item.parent.name || item?.name || 'Unknown Product', - name: item?.name, + name: item?.name, description: `${item.code} - ${item.name}`, value: item.price.priceDiscount, weight: item.weight * 1000, @@ -27,61 +27,71 @@ function mappingItems(products) { } function reverseMappingCourier(couriersOdoo, couriers, includeInstant = false) { - // Buat peta courier berdasarkan nama courier dari couriers + // Bangun peta dari hasil Biteship (pakai dua key: code & name) const courierMap = couriers.reduce((acc, item) => { - const { courier_name, courier_code, courier_service_code } = item; - const key = courier_code.toLowerCase(); - - if ( - !includeInstant && (['hours'].includes(item.shipment_duration_unit.toLowerCase()) || item.service_type == 'same_day') - - ) { - return acc; - } + const codeKey = (item.courier_code || '').toLowerCase(); + const nameKey = (item.courier_name || '').toLowerCase(); + + const isInstant = + (item.shipment_duration_unit || '').toLowerCase() === 'hours' || + (item.service_type || '').toLowerCase() === 'same_day'; + if (!includeInstant && isInstant) return acc; + + const ensureEntry = (key) => { + if (!key) return; + if (!acc[key]) { + acc[key] = { + courier_name: item.courier_name, + courier_code: item.courier_code, + service_type: {}, + }; + } + }; - if (!acc[key]) { - acc[key] = { - courier_name: item.courier_name, - courier_code: courier_code, - service_type: {}, - }; - } + ensureEntry(codeKey); + ensureEntry(nameKey); - acc[key].service_type[courier_service_code] = { + const svc = { service_name: item.courier_service_name, duration: item.duration, shipment_range: item.shipment_duration_range, shipment_unit: item.shipment_duration_unit, price: item.price, - service_type: courier_service_code, + service_type: item.courier_service_code, description: item.description, }; + if (codeKey && acc[codeKey]) { + acc[codeKey].service_type[item.courier_service_code] = svc; + } + if (nameKey && acc[nameKey]) { + acc[nameKey].service_type[item.courier_service_code] = svc; + } return acc; }, {}); - // Iterasi berdasarkan couriersOdoo - return couriersOdoo.map((courierOdoo) => { - const courierNameKey = courierOdoo.label.toLowerCase(); - const carrierId = courierOdoo.carrierId; + // Petakan Odoo ke map Biteship dan FILTER hanya yg punya layanan + return couriersOdoo + .map((courierOdoo) => { + const key = (courierOdoo.label || '').toLowerCase(); + const matched = courierMap[key] || null; - const mappedCourier = courierMap[courierNameKey] || false; + if (!matched) return { ...courierOdoo, courier: false }; - if (!mappedCourier) { return { ...courierOdoo, - courier: false, + courier: { + ...matched, + courier_id_odoo: courierOdoo.carrierId, // penting: simpan id Odoo di sini + }, }; - } - - return { - ...courierOdoo, - courier: { - ...mappedCourier, - courier_id_odoo: carrierId, - }, - }; - }); + }) + .filter( + (x) => + x.courier && + x.courier.service_type && + Object.keys(x.courier.service_type).length > 0 + ); } function mappingCourier(couriersOdoo, couriers, notIncludeInstant = false) { @@ -214,7 +224,7 @@ export default function SectionExpedition({ products }) { let data = { products: JSON.stringify(productsMapped), - } + }; const res = await odooApi('POST', `/api/v1/product/variants/sla`, data); setSlaProducts(res); } catch (error) { @@ -328,13 +338,13 @@ export default function SectionExpedition({ products }) { }; useEffect(() => { - if (serviceOptions.length > 0) { - setSavedServiceOptions(serviceOptions); - } -}, [serviceOptions]); + if (serviceOptions.length > 0) { + setSavedServiceOptions(serviceOptions); + } + }, [serviceOptions]); return ( - <form > + <form> <div className='px-4 py-2'> <div className='flex justify-between items-center'> <div className='font-medium'>Pilih Ekspedisi: </div> @@ -371,27 +381,40 @@ export default function SectionExpedition({ products }) { <p className='font-semibold'>SELF PICKUP</p> </div> </div> - {couriers?.map((courier) => ( - <div - key={courier?.courier?.courier_code} - onClick={() => onCourierChange(courier)} - className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' - > - <div> - <p className='font-semibold'> - {courier?.label} - </p> + {couriers + ?.map((c) => c) // sudah ter-filter di reverseMappingCourier, tapi aman buat double-check + .filter( + (c) => + c.courier && + Object.keys(c.courier.service_type).length > 0 + ) + .map((courier) => ( + <div + key={ + courier?.courier?.courier_code || + courier?.label + } + onClick={() => onCourierChange(courier)} + className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' + > + <div> + <p className='font-semibold'> + {courier?.label} + </p> + </div> + <span className='font-semibold'> + <Image + src={courier?.logo} + alt={ + courier?.courier?.courier_name || + courier?.label + } + width={50} + height={50} + /> + </span> </div> - <span className='font-semibold'> - <Image - src={courier?.logo} - alt={courier?.courier?.courier_name} - width={50} - height={50} - /> - </span> - </div> - ))} + ))} </> ) : ( <> @@ -432,8 +455,7 @@ export default function SectionExpedition({ products }) { )} </div> - {(serviceOptions.length > 0 || - selectedService )&& + {(serviceOptions.length > 0 || selectedService) && selectedCourier && selectedCourier !== 32 && selectedCourier !== 0 && ( diff --git a/src/lib/checkout/components/SectionQuotationExpedition.jsx b/src/lib/checkout/components/SectionQuotationExpedition.jsx index b8ea04ef..817cd21b 100644 --- a/src/lib/checkout/components/SectionQuotationExpedition.jsx +++ b/src/lib/checkout/components/SectionQuotationExpedition.jsx @@ -239,7 +239,7 @@ export default function SectionExpeditionQuotation({ products }) { <div className='px-4 py-2'> <div className='flex justify-between items-center'> <div className='font-medium'>Pilih Ekspedisi: </div> - <div className='w-[350px]'> + <div className='relative w-[350px]'> <div className='w-full p-2 border rounded-lg bg-white cursor-pointer' onClick={() => setOnFocuseSelectedCourier(!onFocusSelectedCourier)} @@ -253,7 +253,10 @@ export default function SectionExpeditionQuotation({ products }) { )} </div> {onFocusSelectedCourier && ( - <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 max-h-[200px] overflow-y-auto w-[350px]'> + <div + className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50 + max-h-[200px] overflow-y-auto w-full sm:w-[350px]' + > {!isLoading ? ( <> <div @@ -297,8 +300,8 @@ export default function SectionExpeditionQuotation({ products }) { {checkWeigth && ( <p className='mt-4 text-gray-600'> - Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada barang - yang belum memiliki berat. Silakan hubungi admin via{' '} + Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada + barang yang belum memiliki berat. Silakan hubungi admin via{' '} <a className='text-blue-600 underline' href='https://api.whatsapp.com/send?phone=6281717181922' @@ -316,7 +319,7 @@ export default function SectionExpeditionQuotation({ products }) { selectedCourier !== 0 && ( <div className='mt-4 flex justify-between'> <div className='font-medium mb-2'>Tipe Layanan Ekspedisi:</div> - <div className='relative w-[350px]'> + <div className='relative w-full sm:w-[350px]'> <div className='p-2 border rounded-lg bg-white cursor-pointer' onClick={() => setIsOpen(!isOpen)} @@ -331,11 +334,13 @@ export default function SectionExpeditionQuotation({ products }) { </span> </div> ) : ( - <span className='text-gray-500'>Pilih layanan pengiriman</span> + <span className='text-gray-500'> + Pilih layanan pengiriman + </span> )} </div> {isOpen && ( - <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 w-full'> + <div className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50 w-full'> {serviceOptions.map((service) => ( <div key={service.service_type} diff --git a/src/lib/form/components/Merchant.jsx b/src/lib/form/components/Merchant.jsx index 03b8fc84..b65449a8 100644 --- a/src/lib/form/components/Merchant.jsx +++ b/src/lib/form/components/Merchant.jsx @@ -147,7 +147,7 @@ const CreateMerchant = () => { <p> Penjualan online adalah hal yang HARUS dilakukan mulai sekarang. Perubahan dalam banyak industri dan pola pembelian. Gabung dengan - platform kami dan mulai penjualan lansung di ribuan perusahaan d + platform kami dan mulai penjualan langsung di ribuan perusahaan di seluruh Indonesia.{' '} </p> </div> diff --git a/src/lib/maps/components/PinPointMap.jsx b/src/lib/maps/components/PinPointMap.jsx index c46d838a..a9ead055 100644 --- a/src/lib/maps/components/PinPointMap.jsx +++ b/src/lib/maps/components/PinPointMap.jsx @@ -14,37 +14,54 @@ const containerStyle = { height: '400px', }; -const defaultCenter = { - lat: -6.2, - lng: 106.816666, -}; - -const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) => { +const PinpointLocation = ({ + initialLatitude, + initialLongitude, + initialAddress, +}) => { const { isLoaded } = useJsApiLoader({ googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY, libraries: ['places'], }); const { - addressMaps, setAddressMaps, selectedPosition, setSelectedPosition, setDetailAddress, setPinedMaps, + getDefaultCenter, // ✅ ambil default center dari store } = useMaps(); + + const [tempAddress, setTempAddress] = useState(initialAddress || ''); const [tempPosition, setTempPosition] = useState( - initialLatitude && initialLongitude + initialLatitude && initialLongitude ? { lat: parseFloat(initialLatitude), lng: parseFloat(initialLongitude) } - : selectedPosition.lat && selectedPosition.lng - ? selectedPosition - : defaultCenter + : selectedPosition?.lat && selectedPosition?.lng + ? selectedPosition + : getDefaultCenter() // ✅ fallback aman untuk view ); - const [markerIcon, setMarkerIcon] = useState(null); + useEffect(() => { + if (initialLatitude && initialLongitude) { + const lat = parseFloat(initialLatitude); + const lng = parseFloat(initialLongitude); + + if (!isNaN(lat) && !isNaN(lng)) { + setTempPosition({ lat, lng }); + // kalau address belum ada, reverse geocode + if (!initialAddress) { + getAddress(lat, lng); + } else { + setTempAddress(initialAddress); + } + } + } + }, [initialLatitude, initialLongitude, initialAddress]); + const [markerIcon, setMarkerIcon] = useState(null); const autocompleteRef = useRef(null); useEffect(() => { @@ -55,7 +72,7 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) }); } - // If we have initial coordinates but no address, fetch the address + // Jika ada koordinat awal tapi belum ada address → reverse geocode if (initialLatitude && initialLongitude && !initialAddress) { getAddress(parseFloat(initialLatitude), parseFloat(initialLongitude)); } @@ -66,10 +83,11 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) return component ? component.long_name : ''; }; + // fill from pin point const getAddress = async (lat, lng) => { try { const response = await fetch( - `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.NEXT_PUBLIC_GOOGLE_API_KEY}` + `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.NEXT_PUBLIC_GOOGLE_API_KEY}&language=id` ); const data = await response.json(); @@ -78,14 +96,31 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) const formattedAddress = data.results[0].formatted_address; const details = { - street: - getAddressComponent(addressComponents, 'route') + - ' ' + + street: [ + getAddressComponent(addressComponents, 'route'), getAddressComponent(addressComponents, 'street_number'), - province: getAddressComponent(addressComponents, 'administrative_area_level_1'), - district: getAddressComponent(addressComponents, 'administrative_area_level_2'), - subDistrict: getAddressComponent(addressComponents, 'administrative_area_level_3'), - village: getAddressComponent(addressComponents, 'administrative_area_level_4'), + getAddressComponent(addressComponents, 'administrative_area_level_4'), + getAddressComponent(addressComponents, 'administrative_area_level_3'), + getAddressComponent(addressComponents, 'administrative_area_level_2'), + getAddressComponent(addressComponents, 'administrative_area_level_1'), + getAddressComponent(addressComponents, 'postal_code'), + ].filter(Boolean).join(', '), + province: getAddressComponent( + addressComponents, + 'administrative_area_level_1' + ), + district: getAddressComponent( + addressComponents, + 'administrative_area_level_2' + ), + subDistrict: getAddressComponent( + addressComponents, + 'administrative_area_level_3' + ), + village: getAddressComponent( + addressComponents, + 'administrative_area_level_4' + ), postalCode: getAddressComponent(addressComponents, 'postal_code'), }; @@ -136,8 +171,15 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) const handleSavePinpoint = (event) => { event.preventDefault(); - if (tempAddress === '') { - alert('Silahkan pilih lokasi terlebih dahulu'); + + // ✅ cegah save jika masih di default center (user belum benar2 pilih lokasi) + const dc = getDefaultCenter(); + const isDefault = + Math.abs(tempPosition.lat - dc.lat) < 1e-6 && + Math.abs(tempPosition.lng - dc.lng) < 1e-6; + + if (!tempAddress || isDefault) { + alert('Silahkan pilih lokasi di peta atau autocomplete terlebih dahulu'); return; } @@ -173,13 +215,13 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) {isLoaded ? ( <GoogleMap mapContainerStyle={containerStyle} - center={tempPosition} + center={tempPosition || getDefaultCenter()} // ✅ aman jika null zoom={15} onClick={onMapClick} > {markerIcon && ( <Marker - position={tempPosition} + position={tempPosition || getDefaultCenter()} // ✅ selalu ada posisi draggable={true} onDragEnd={(e) => { const lat = e.latLng.lat(); @@ -199,7 +241,8 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) <div style={{ marginTop: '20px' }}> <Button variant='solid' onClick={handleUseCurrentLocation}> - <LocateFixed className='h-6 w-6 text-gray-500 mr-2' /> Gunakan Lokasi Saat Ini + <LocateFixed className='h-6 w-6 text-gray-500 mr-2' /> Gunakan Lokasi + Saat Ini </Button> </div> diff --git a/src/lib/maps/stores/useMaps.js b/src/lib/maps/stores/useMaps.js index c57a05ad..b02c2ae3 100644 --- a/src/lib/maps/stores/useMaps.js +++ b/src/lib/maps/stores/useMaps.js @@ -1,32 +1,57 @@ import { create } from "zustand"; -const center = { - lat: -6.200000, // Default latitude (Jakarta) - lng: 106.816666, // Default longitude (Jakarta) -}; - -export const useMaps = create((set) => ({ - // State existing - selectedPosition: center, +const DEFAULT_CENTER = { lat: -6.2, lng: 106.816666 }; + +export const useMaps = create((set, get) => ({ + // ==== STATE ==== + selectedPosition: null, addressMaps: '', detailAddress: {}, pinedMaps: false, - // State tambahan untuk penyimpanan posisi sementara + // posisi sementara (create/edit) tempPositionCreate: null, tempPositionEdit: null, - // Setter existing + // ==== SETTERS ==== setSelectedPosition: (position) => set({ selectedPosition: position }), setAddressMaps: (addressMaps) => set({ addressMaps }), setDetailAddress: (detailAddress) => set({ detailAddress }), setPinedMaps: (pinedMaps) => set({ pinedMaps }), - // Setter tambahan untuk posisi sementara setTempPositionCreate: (position) => set({ tempPositionCreate: position }), setTempPositionEdit: (position) => set({ tempPositionEdit: position }), - // Opsional: Reset jika ingin clear saat keluar halaman resetTempPositionCreate: () => set({ tempPositionCreate: null }), resetTempPositionEdit: () => set({ tempPositionEdit: null }), + + getDefaultCenter: () => DEFAULT_CENTER, + + isPinned: () => { + const p = get().selectedPosition; + if (!p) return false; + + // parse string -> number + const lat = parseFloat(p.lat); + const lng = parseFloat(p.lng); + + // cek kalau bukan angka valid + if (isNaN(lat) || isNaN(lng)) return false; + + // cek apakah sama dengan default + const isDefault = + Math.abs(lat - DEFAULT_CENTER.lat) < 1e-6 && + Math.abs(lng - DEFAULT_CENTER.lng) < 1e-6; + + return !isDefault; + }, + + resetPin: () => set({ + selectedPosition: null, + addressMaps: '', + detailAddress: {}, + pinedMaps: false, + tempPositionCreate: null, + tempPositionEdit: null, + }), })); diff --git a/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js b/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js index af1d6c3a..173287de 100644 --- a/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js +++ b/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js @@ -11,4 +11,4 @@ const createPengajuanTempoApi = async (data) => { return dataPengajuanTempo; }; -export default createPengajuanTempoApi; +export default createPengajuanTempoApi;
\ No newline at end of file diff --git a/src/lib/pengajuan-tempo/component/FinishTempo.jsx b/src/lib/pengajuan-tempo/component/FinishTempo.jsx index aacb9ef3..abf218d9 100644 --- a/src/lib/pengajuan-tempo/component/FinishTempo.jsx +++ b/src/lib/pengajuan-tempo/component/FinishTempo.jsx @@ -8,13 +8,19 @@ import useAuth from '@/core/hooks/useAuth'; import axios from 'axios'; import { toast } from 'react-hot-toast'; import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline'; +import { useRouter } from 'next/router'; +import switchAccountProgresApi from '@/lib/auth/api/switchAccountProgresApi.js'; const FinishTempo = ({ query }) => { const [data, setData] = useState(); + const [switchStatus, setSwitchStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); const [transactionData, setTransactionData] = useState(); const { isDesktop, isMobile } = useDevice(); const auth = useAuth(); const so_order = query?.order_id?.replaceAll('-', '/'); + const router = useRouter(); + useEffect(() => { const fetchData = async () => { const fetchedData = await odooApi( @@ -26,8 +32,46 @@ const FinishTempo = ({ query }) => { fetchData(); }, [query]); + useEffect(() => { + const fetchSwitchStatus = async () => { + try { + const progres = await switchAccountProgresApi(); + setSwitchStatus(progres?.status); + } catch (err) { + console.error('Gagal cek progres switch account:', err); + } finally { + setLoadingStatus(false); + } + }; + fetchSwitchStatus(); + }, []); + + // Handler khusus untuk tombol Ubah Akun + const handleSwitchAccountClick = () => { + if (switchStatus === 'pending') { + toast.info('Akun sedang menunggu verifikasi. Tidak dapat mengubah akun saat ini.', { duration: 2500 }); + return; + } + localStorage.setItem('autoCheckProfile', 'true'); + router.push('/my/profile'); + }; + + if (loadingStatus) { + return ( + <div className="container flex flex-col items-center gap-4"> + <div className="py-20 text-gray-500">Memuat data...</div> + </div> + ); + } + return ( - <div className='container flex flex-col items-center gap-4'> + <div + className={`container flex flex-col items-center gap-4 pb-20 ${ + switchStatus === 'pending' + ? 'min-h-[calc(100vh-80px)] md:min-h-[calc(100vh-100px)] lg:min-h-[calc(100vh-120px)]' + : 'min-h-screen' + }`} + > <div className={`flex ${ isMobile ? 'w-full' : 'w-2/3' @@ -40,8 +84,11 @@ const FinishTempo = ({ query }) => { > {query?.status == 'finish' && 'Form Pengajuan Tempo kamu Telah Berhasil Didaftarkan Mohon menunggu hingga Proses Verifikasi Selesai Dilakukan'} - {query?.status == 'switch-account' && - 'Form Pengajuan Tempo Kamu Gagal Dilakukan'} + {switchStatus === 'pending' + ? 'Form Pengajuan Tempo Kamu Belum Dapat Dilakukan' + : query?.status == 'switch-account' && + 'Form Pengajuan Tempo Kamu Gagal Dilakukan' + } {query?.status == 'review' && 'Pengajuan Tempo dalam Proses Verifikasi'} {query?.status == 'approve' && 'Pengajuan Tempo Berhasil'} @@ -85,8 +132,11 @@ const FinishTempo = ({ query }) => { isMobile ? 'w-full text-sm' : 'w-4/5 text-base' }`} > - {query?.status == 'switch-account' && - 'Terima kasih atas minat anda untuk mendaftar Tempo, namun sayangnya akun anda bukan merupakan akun bisnis. Segera ubah akun anda menjadi Bisnis untuk menggunakan fitur ini'} + {switchStatus === 'pending' + ? 'Proses perubahan ke akun bisnis sedang kami review, mohon menunggu hingga 2x24 jam' + : query?.status == 'switch-account' && + 'Terima kasih atas minat anda untuk mendaftar Tempo, namun sayangnya akun anda bukan merupakan akun bisnis. Segera ubah akun anda menjadi Bisnis untuk menggunakan fitur ini' + } {query?.status == 'finish' && 'Mohon menunggu untuk verifikasi dokumen dan kelengkapan data yang telah anda berikan. Proses approval pembayaran tempo kamu berhasil atau tidak akan diinfokan melalui email perusahaan / email yang mendaftar'} {query?.status == 'review' && @@ -94,23 +144,59 @@ const FinishTempo = ({ query }) => { {query?.status == 'approve' && 'Proses pengajuan tempo anda sudah berhasil terdaftar di indoteknik.com. Nikmati pembelian anda di website indoteknik dengan menggunakan pembayaran tempo'} </div> - <Link - href={ - query?.status === 'switch-account' - ? '/my/profile' - : query?.status === 'approve' - ? '/my/tempo/' - : '/' - } - className='btn-solid-red rounded-md text-base flex flex-row items-center justify-center' - > - {query?.status === 'switch-account' - ? 'Ubah Akun' - : query?.status === 'approve' - ? 'Lihat Detail Tempo' - : 'Kembali Ke Beranda'} - <ChevronRightIcon className='w-5' /> - </Link> + + {switchStatus !== 'pending' && ( + <hr className="border-gray-300 w-full" /> + )} + + {/* Video panduan khusus tampil saat status switch-account */} + {query?.status === 'switch-account' && switchStatus !== 'pending' && ( + <div className="w-full max-w-3xl mx-auto px-4 text-center text-gray-700 mb-6"> + <div className="mb-3 font-medium"> + <h1 + className={`text-red-500 text-center font-semibold ${ + isMobile ? 'text-lg' : 'text-3xl' + }`} + // Mengganti py-4 dengan my-6 supaya jarak vertikalnya sama dengan hr + style={{ marginTop: 24, marginBottom: 24 }} + > + Video Panduan Pengajuan Tempo + </h1> + </div> + <div className="relative" style={{ paddingTop: '56.25%' /* 16:9 aspect ratio */ }}> + <iframe + src="https://www.youtube.com/embed/m15f8-eLqUc?si=frNbGnJu1zjINnDT" + title="YouTube video player" + frameBorder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + allowFullScreen + referrerPolicy="strict-origin-when-cross-origin" + className="absolute top-0 left-0 w-full h-full rounded-md shadow-lg" + ></iframe> + </div> + </div> + )} + + {/* Tombol dengan behavior berbeda jika status switch-account */} + {query?.status === 'switch-account' && switchStatus !== 'pending' ? ( + <button + onClick={handleSwitchAccountClick} + className="btn-solid-red rounded-md text-base flex flex-row items-center justify-center mb-10" + > + Ubah Akun + <ChevronRightIcon className="w-5" /> + </button> + ) : query?.status !== 'switch-account' && ( + <Link + href={query?.status === 'approve' ? '/my/tempo/' : '/'} + className="btn-solid-red rounded-md text-base flex flex-row items-center justify-center mb-10" + > + {query?.status === 'approve' + ? 'Lihat Detail Tempo' + : 'Kembali Ke Beranda'} + <ChevronRightIcon className="w-5" /> + </Link> + )} </div> ); }; diff --git a/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx b/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx index ae3d97fd..096fe1ed 100644 --- a/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx +++ b/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx @@ -40,6 +40,7 @@ const PengajuanTempo = () => { const { form, errors, validate, updateForm } = usePengajuanTempoStore(); const { control, watch, setValue, setError } = useForm(); const auth = useAuth(); + console.log('auth', auth); const router = useRouter(); const [BannerTempo, setBannerTempo] = useState(); const { formDokumen, errorsDokumen, validateDokumen, updateFormDokumen } = diff --git a/src/lib/product/components/Product/ProductDesktopVariant.jsx b/src/lib/product/components/Product/ProductDesktopVariant.jsx index de88e5bb..59fa2032 100644 --- a/src/lib/product/components/Product/ProductDesktopVariant.jsx +++ b/src/lib/product/components/Product/ProductDesktopVariant.jsx @@ -103,15 +103,6 @@ const ProductDesktopVariant = ({ variantQuantityRefs.current[variantId] = element; }; - const validQuantity = (quantity) => { - let isValid = true; - if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { - toast.error('Jumlah barang minimal 1'); - isValid = false; - } - return isValid; - }; - const handleAddToCart = (variant) => { if (!auth) { router.push(`/login?next=/shop/product/${slug}?srsltid=${srsltid}`); @@ -132,29 +123,36 @@ const ProductDesktopVariant = ({ setAddCartAlert(true); }; + const toInt = (v) => { + const n = parseInt(String(v ?? '').trim(), 10); + return Number.isFinite(n) ? n : 0; + }; + + const validQuantity = (q) => { + if (!Number.isInteger(q) || q < 1) { + toast.error('Jumlah barang minimal 1'); + return false; + } + return true; + }; + const handleBuy = async (variant) => { - const quantity = variantQuantityRefs?.current[product.id]?.value; - let isLoggedIn = typeof auth === 'object'; + const quantity = Math.max(1, toInt(quantityInput)); // clamp min 1 + let isLoggedIn = typeof auth === 'object'; if (!isLoggedIn) { const currentUrl = encodeURIComponent(router.asPath); await router.push(`/login?next=${currentUrl}`); - // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. - const authCheckInterval = setInterval(() => { - const newAuth = getAuth(); - if (typeof newAuth === 'object') { - isLoggedIn = true; - auth = newAuth; // Update nilai auth setelah login - clearInterval(authCheckInterval); - } - }, 500); // Periksa status login setiap 500ms - + // tunggu sampai auth ada await new Promise((resolve) => { - const checkLogin = setInterval(() => { - if (isLoggedIn) { - clearInterval(checkLogin); - resolve(null); + const t = setInterval(() => { + const newAuth = getAuth(); + if (typeof newAuth === 'object') { + isLoggedIn = true; + auth = newAuth; + clearInterval(t); + resolve(); } }, 500); }); @@ -162,16 +160,57 @@ const ProductDesktopVariant = ({ if (!validQuantity(quantity)) return; - updateItemCart({ + await updateItemCart({ productId: variant, quantity, programLineId: null, selected: true, source: 'buy', }); - router.push(`/shop/checkout?source=buy`); + + router.push('/shop/checkout?source=buy'); }; + // const handleBuy = async (variant) => { + // const quantity = variantQuantityRefs?.current[product.id]?.value; + // let isLoggedIn = typeof auth === 'object'; + + // if (!isLoggedIn) { + // const currentUrl = encodeURIComponent(router.asPath); + // await router.push(`/login?next=${currentUrl}`); + + // // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. + // const authCheckInterval = setInterval(() => { + // const newAuth = getAuth(); + // if (typeof newAuth === 'object') { + // isLoggedIn = true; + // auth = newAuth; // Update nilai auth setelah login + // clearInterval(authCheckInterval); + // } + // }, 500); // Periksa status login setiap 500ms + + // await new Promise((resolve) => { + // const checkLogin = setInterval(() => { + // if (isLoggedIn) { + // clearInterval(checkLogin); + // resolve(null); + // } + // }, 500); + // }); + // } + + // if (!validQuantity(quantity)) return; + + // updateItemCart({ + // productId: variant, + // quantity, + // programLineId: null, + // selected: true, + // source: 'buy', + // }); + // router.push(`/shop/checkout?source=buy`); + // }; + const handleButton = async (variant) => { const quantity = quantityInput; let isLoggedIn = typeof auth === 'object'; @@ -443,37 +482,53 @@ const ProductDesktopVariant = ({ )} </h3> )} - <div className='flex justify-between items-center py-5 px-3'> + <div className='flex gap-x-5 items-center py-5'> <div className='relative flex items-center'> <button type='button' className='absolute left-0 px-2 py-1 h-full text-gray-500' - onClick={() => - setQuantityInput( - String(Math.max(1, Number(quantityInput) - 1)) - ) - } + onClick={() => { + const n = parseInt(String(quantityInput), 10); + const next = Number.isFinite(n) ? Math.max(1, n - 1) : 1; + setQuantityInput(next); + }} > - </button> + <input type='number' id='quantity' min={1} + step={1} + inputMode='numeric' + pattern='[0-9]*' value={quantityInput} - onChange={(e) => setQuantityInput(e.target.value)} - className=' w-24 h-10 text-center border border-gray-300 rounded focus:outline-none' + onChange={(e) => { + const raw = e.target.value.trim(); + const n = parseInt(raw, 10); + setQuantityInput(Number.isFinite(n) && n > 0 ? n : 1); + }} + onKeyDown={(e) => { + if (['e', 'E', '+', '-', '.'].includes(e.key)) + e.preventDefault(); + }} + className='w-24 h-10 text-center border border-gray-300 rounded focus:outline-none' /> + <button type='button' className='absolute right-0 px-2 py-1 h-full text-gray-500' - onClick={() => - setQuantityInput(String(Number(quantityInput) + 1)) - } + onClick={() => { + const n = parseInt(String(quantityInput), 10); + const next = (Number.isFinite(n) ? n : 0) + 1; + setQuantityInput(next); + }} > + </button> </div> + <div> <Skeleton isLoaded={!isLoadingSLA} @@ -510,7 +565,8 @@ const ProductDesktopVariant = ({ <Button onClick={() => handleAddToCart(product.id)} className='w-full' - colorScheme='yellow' + colorScheme='red' + variant={'outline'} > Keranjang </Button> @@ -529,7 +585,7 @@ const ProductDesktopVariant = ({ className='w-full border-2 p-2 gap-1 mt-2 hover:bg-slate-100 flex items-center' > <ImageNext - src='/images/writing.png' + src='/images/doc_red.svg' alt='penawaran instan' className='' width={25} diff --git a/src/lib/product/components/Product/ProductMobileVariant.jsx b/src/lib/product/components/Product/ProductMobileVariant.jsx index de5c3f10..cab8e9be 100644 --- a/src/lib/product/components/Product/ProductMobileVariant.jsx +++ b/src/lib/product/components/Product/ProductMobileVariant.jsx @@ -27,6 +27,8 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { let auth = getAuth(); const [quantity, setQuantity] = useState('1'); const [selectedVariant, setSelectedVariant] = useState(product.id); + const [quantityInput, setQuantityInput] = useState(String(1)); + const [qtyPickUp, setQtyPickUp] = useState(0); const [informationTab, setInformationTab] = useState( informationTabOptions[0].value ); @@ -63,30 +65,33 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { } }, [selectedVariant, product]); - const validAction = () => { - let isValid = true; + const validAction = (q) => { if (!selectedVariant) { toast.error('Pilih varian terlebih dahulu'); - isValid = false; + return false; } - if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { + if (!Number.isInteger(q) || q < 1) { toast.error('Jumlah barang minimal 1'); - isValid = false; + return false; } - return isValid; + return true; }; + const getQty = () => Math.max(1, toInt(quantityInput)); + const handleClickCart = async () => { + const q = getQty(); + if (!auth) { router.push(`/login?next=/shop/product/${slug}?srsltid=${srsltid}`); return; } + if (!validAction(q)) return; - if (!validAction()) return; - gtagAddToCart(activeVariant, quantity); + gtagAddToCart(activeVariant, q); updateItemCart({ productId: product.id, - quantity, + quantity: q, programLineId: null, selected: true, source: null, @@ -95,37 +100,29 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { }; const handleClickBuy = async () => { - let isLoggedIn = typeof auth === 'object'; + const q = getQty(); + let isLoggedIn = typeof auth === 'object'; if (!isLoggedIn) { const currentUrl = encodeURIComponent(router.asPath); await router.push(`/login?next=${currentUrl}`); - - // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. - const authCheckInterval = setInterval(() => { - const newAuth = getAuth(); - if (typeof newAuth === 'object') { - isLoggedIn = true; - auth = newAuth; // Update nilai auth setelah login - clearInterval(authCheckInterval); - } - }, 500); // Periksa status login setiap 500ms - await new Promise((resolve) => { - const checkLogin = setInterval(() => { - if (isLoggedIn) { - clearInterval(checkLogin); + const t = setInterval(() => { + const newAuth = getAuth(); + if (typeof newAuth === 'object') { + auth = newAuth; + clearInterval(t); resolve(null); } }, 500); }); } - if (!validAction()) return; + if (!validAction(q)) return; updateItemCart({ productId: product.id, - quantity, + quantity: q, programLineId: null, selected: true, source: 'buy', @@ -133,8 +130,21 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { router.push(`/shop/checkout?source=buy`); }; + const toInt = (v) => { + const n = parseInt(String(v ?? '').trim(), 10); + return Number.isFinite(n) ? n : 0; + }; + + const validQuantity = (q) => { + if (!Number.isInteger(q) || q < 1) { + toast.error('Jumlah barang minimal 1'); + return false; + } + return true; + }; + const handleButton = (variant) => { - const quantity = quantityInput; + const quantity = Math.max(1, toInt(quantityInput)); // clamp min 1 if (!validQuantity(quantity)) return; updateItemCart({ @@ -168,9 +178,10 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { return ( <MobileView> - <div - className={`px-4 block md:sticky md:top-[150px] md:py-6 fixed bottom-0 left-0 right-0 bg-white p-2 z-10 pb-6 pt-6 rounded-lg shadow-[rgba(0,0,4,0.1)_0px_-4px_4px_0px] `} - > + {/* PRICE & ACTIONS: tetap punyamu, hanya hapus input number lama */} + {/* ===== BAR BAWAH (fixed) ===== */} + <div className='px-4 fixed bottom-0 left-0 right-0 bg-white z-10 pb-6 pt-4 rounded-t-2xl shadow-[rgba(0,0,4,0.1)_0px_-4px_4px_0px]'> + {/* HARGA & PPN (logikamu tetap) */} {activeVariant.isFlashSale && activeVariant?.price?.discountPercentage > 0 ? ( <> @@ -225,50 +236,105 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { )} </div> )} - <div className=''> - <div className='mt-4 mb-2'>Jumlah</div> - <div className='flex gap-x-3'> - <div className='w-2/12'> + + {/* ⬇️ TAMBAHKAN BLOK INI DI DALAM BAR: STOK & STEPPER */} + <div className='grid grid-cols-12 items-center gap-3 mt-3'> + <div className='col-span-7'> + <div + className={`text-[14px] ${ + product?.sla?.qty < 10 ? 'text-red-600 font-medium' : '' + }`} + > + Stock : {activeVariant?.stock ?? 0} + </div> + {qtyPickUp > 0 && ( + <div className='text-[16px] mt-0.5 text-red-500 italic'> + * {qtyPickUp} barang bisa di pickup + </div> + )} + </div> + <div className='col-span-5 flex justify-end'> + <div className='inline-flex items-stretch border rounded-xl overflow-hidden'> + <button + type='button' + className='h-10 w-10 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95' + onClick={() => + setQuantityInput( + String(Math.max(1, Number(quantityInput || 1) - 1)) + ) + } + aria-label='Kurangi' + > + <span className='text-2xl leading-none'>–</span> + </button> <input - name='quantity' type='number' - className='form-input' - value={quantity} - onChange={(e) => setQuantity(e.target.value)} + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className='h-10 w-16 text-center text-lg outline-none border-x + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none' /> + <button + type='button' + className='h-10 w-10 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95' + onClick={() => + setQuantityInput(String(Number(quantityInput || 1) + 1)) + } + aria-label='Tambah' + > + <span className='text-2xl leading-none'>+</span> + </button> </div> - <button - type='button' - className='btn-yellow flex-1' - onClick={handleClickCart} - > - Keranjang - </button> - <button - type='button' - className='btn-solid-red flex-1' - onClick={handleClickBuy} - > - Beli - </button> </div> + </div> + + <div className='h4'/> + {/* TOMBOL AKSI */} + <div className='flex gap-2 mt-3'> + {/* Tombol Dokumen */} <Button onClick={() => handleButton(product.id)} - color={'red'} - colorScheme='white' - className='w-full border-2 p-2 gap-1 mt-2 hover:bg-slate-100 flex items-center' + className='flex items-center justify-center p-2 border-2 hover:bg-slate-100' + variant='outline' + title='Lihat Dokumen' > <ImageNext - src='/images/writing.png' - alt='penawaran instan' - className='' - width={25} - height={25} + src='/images/doc.svg' + width={18} + height={18} + alt='Dokumen' /> - Penawaran Harga Instan </Button> + {/* Container untuk tombol aksi utama */} + <div className='flex-1 flex gap-2'> + <Button + onClick={() => + handleClickCart(product.id, Number(quantityInput || 1)) + } + className='flex-1' + colorScheme='red' + variant='outline' + isDisabled={product.stock === 0} + > + Keranjang + </Button> + <Button + onClick={() => + handleClickBuy(product.id, Number(quantityInput || 1)) + } + className='flex-1' + colorScheme='red' + isDisabled={product.stock === 0} + > + Beli + </Button> + </div> </div> </div> + <Image src={product.image + '?variant=True'} alt={product.name} diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx index a8ed90a4..f4f5882e 100644 --- a/src/lib/product/components/ProductCard.jsx +++ b/src/lib/product/components/ProductCard.jsx @@ -73,7 +73,7 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { if (variant == 'vertical') { return ( - <div className='rounded shadow-sm border border-gray_r-4 bg-white h-[330px] md:h-[380px]'> + <div className='rounded shadow-sm border border-gray_r-4 bg-white'> <Link href={URL.product} className='border-b border-gray_r-4 relative' aria-label='Produk'> <div className='relative'> <Image diff --git a/src/lib/product/components/ProductFilterDesktop.jsx b/src/lib/product/components/ProductFilterDesktop.jsx index d2ecb4d9..440e1795 100644 --- a/src/lib/product/components/ProductFilterDesktop.jsx +++ b/src/lib/product/components/ProductFilterDesktop.jsx @@ -1,7 +1,6 @@ import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import _ from 'lodash'; -import { toQuery } from 'lodash-contrib'; import { Accordion, AccordionButton, @@ -9,7 +8,6 @@ import { AccordionItem, AccordionPanel, Box, - Button, Checkbox, Input, InputGroup, @@ -17,136 +15,200 @@ import { Stack, VStack, } from '@chakra-ui/react'; -import Image from '@/core/components/elements/Image/Image'; import { formatCurrency } from '@/core/utils/formatValue'; const ProductFilterDesktop = ({ - brands, - categories, + brands, // bisa [{id,name,qty}] atau [{brand,qty}] + categories, // [{name, qty}] prefixUrl, - defaultBrand = null, }) => { const router = useRouter(); - const { query } = router; - const [order, setOrder] = useState(query?.orderBy); - const [brandValues, setBrand] = useState(query?.brand?.split(',') || []); + + const [order, setOrder] = useState(router.query?.orderBy); + const [brandValues, setBrand] = useState( + typeof router.query?.brand === 'string' && router.query.brand + ? router.query.brand.split(',').filter(Boolean) + : [] + ); const [categoryValues, setCategory] = useState( - query?.category?.split(',') || [] + typeof router.query?.category === 'string' && router.query.category + ? router.query.category.split(',').filter(Boolean) + : [] ); - const [priceFrom, setPriceFrom] = useState(query?.priceFrom); - const [priceTo, setPriceTo] = useState(query?.priceTo); - const [stock, setStock] = useState(query?.stock); + const [priceFrom, setPriceFrom] = useState(router.query?.priceFrom ?? ''); + const [priceTo, setPriceTo] = useState(router.query?.priceTo ?? ''); + const [stock, setStock] = useState(router.query?.stock ?? null); const [activeRange, setActiveRange] = useState(null); - const [activeIndeces, setActiveIndeces] = useState([]); + + const handlePriceKeyDown = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + // keluar dari preset kalau user input manual + setActiveRange(null); + + // pakai state terkini untuk apply + const fromVal = priceFrom === '' ? '' : String(priceFrom); + const toVal = priceTo === '' ? '' : String(priceTo); + + applyFilters({ priceFrom: fromVal, priceTo: toVal }); + }; + + // --- normalisasi data brand agar tahan banting --- + const normBrands = useMemo(() => { + return (brands ?? []) + .map((b, i) => ({ + id: String(b.id ?? b.val ?? b.brand ?? i), + name: String(b.name ?? b.brand ?? b.label ?? b.val ?? '').trim(), + qty: b.qty ?? b.count, + })) + .filter((b) => b.name); + }, [brands]); const priceRange = [ - { - priceFrom: 100000, - priceTo: 200000, - }, - { - priceFrom: 200000, - priceTo: 300000, - }, - { - priceFrom: 300000, - priceTo: 400000, - }, - { - priceFrom: 400000, - priceTo: 500000, - }, + { priceFrom: 100000, priceTo: 200000 }, + { priceFrom: 200000, priceTo: 300000 }, + { priceFrom: 300000, priceTo: 400000 }, + { priceFrom: 400000, priceTo: 500000 }, ]; - const indexRange = priceRange.findIndex((range) => { - return ( - range.priceFrom === parseInt(priceFrom) && - range.priceTo == parseInt(priceTo) - ); - }); - - const handleCategoriesChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setCategory([...categoryValues, value]); - } else { - setCategory(categoryValues.filter((val) => val !== value)); - } - }; - const handleBrandsChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setBrand([...brandValues, value]); - } else { - setBrand(brandValues.filter((val) => val !== value)); + const indexRange = priceRange.findIndex( + (r) => r.priceFrom === parseInt(priceFrom) && r.priceTo == parseInt(priceTo) + ); + + const applyFilters = (changes = {}) => { + const params = new URLSearchParams(); + + // 1) salin SEMUA param yang ada sekarang (jangan hilangkan apapun) + Object.entries(router.query).forEach(([k, v]) => { + if (v == null) return; + if (Array.isArray(v)) { + // penting: fq bisa multi-value; gunakan append, bukan join + v.forEach((item) => params.append(k, String(item))); + } else { + params.set(k, String(v)); + } + }); + + // 2) baca nilai dasar langsung dari URL (hindari state stale) + const arr = (val) => + typeof val === 'string' && val ? val.split(',').filter(Boolean) : []; + + const nextBrand = + 'brandValues' in changes ? changes.brandValues : arr(router.query.brand); + const nextCategory = + 'categoryValues' in changes + ? changes.categoryValues + : arr(router.query.category); + const nextPriceFrom = + 'priceFrom' in changes ? changes.priceFrom : router.query.priceFrom ?? ''; + const nextPriceTo = + 'priceTo' in changes ? changes.priceTo : router.query.priceTo ?? ''; + const nextStock = + 'stock' in changes ? changes.stock : router.query.stock ?? null; + const nextOrder = + 'order' in changes ? changes.order : router.query.orderBy ?? ''; + + const setOrDel = (key, val) => { + const empty = + val == null || val === '' || (Array.isArray(val) && val.length === 0); + if (empty) params.delete(key); + else params.set(key, Array.isArray(val) ? val.join(',') : String(val)); + }; + + setOrDel('brand', nextBrand); + setOrDel('category', nextCategory); + setOrDel('priceFrom', nextPriceFrom); + setOrDel('priceTo', nextPriceTo); + setOrDel('stock', nextStock); + setOrDel('orderBy', nextOrder); + + // 3) kalau ada perubahan filter utama → reset page ke 1 + const changedKeys = Object.keys(changes); + const touched = [ + 'brandValues', + 'categoryValues', + 'priceFrom', + 'priceTo', + 'stock', + 'order', + ]; + if (changedKeys.some((k) => touched.includes(k))) { + params.set('page', '1'); } + + // 4) shallow replace (tanpa reload penuh) + const base = router.asPath.split('?')[0]; + router.replace(`${base}?${params.toString()}`, undefined, { + shallow: true, + scroll: false, + }); }; - const handleReadyStockChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setStock(value); - } else { - setStock(null); - } + // debounce untuk input harga (biar nggak spam) + const debouncedApply = useMemo(() => _.debounce(applyFilters, 350), []); // eslint-disable-line + useEffect(() => () => debouncedApply.cancel(), [debouncedApply]); + + // === handlers === + const handleCategoriesChange = (e) => { + const { value, checked } = e.target; + const next = checked + ? [...categoryValues, value] + : categoryValues.filter((v) => v !== value); + setCategory(next); + applyFilters({ categoryValues: next }); }; - const handlePriceFromChange = async (priceFromr, priceTor, index) => { - await setPriceFrom(priceFromr); - await setPriceTo(priceTor); - setActiveRange(index); + const handleBrandsChange = (e) => { + const { value, checked } = e.target; // value = brand ID/name (string) + const next = checked + ? [...brandValues, value] + : brandValues.filter((v) => v !== value); + setBrand(next); + applyFilters({ brandValues: next }); }; - const handleSubmit = () => { - let params = { - penawaran: router.query.penawaran, - q: router.query.q, - orderBy: order, - brand: brandValues.join(','), - category: categoryValues.join(','), - priceFrom, - priceTo, - stock: stock, - }; - params = _.pickBy(params, _.identity); - params = toQuery(params); + const handleReadyStockChange = (e) => { + const { checked, value } = e.target; + const next = checked ? value : null; + setStock(next); + applyFilters({ stock: next }); + }; - const slug = Array.isArray(router.query.slug) - ? router.query.slug[0] - : router.query.slug; + const handlePriceRangeClick = async (pf, pt, idx) => { + await setPriceFrom(pf); + await setPriceTo(pt); + setActiveRange(idx); + applyFilters({ priceFrom: pf, priceTo: pt }); + }; - if (slug) { - if (prefixUrl.includes('category') || prefixUrl.includes('lob')) { - router.push(`${prefixUrl}?${params}`); - } else { - router.push(`${prefixUrl}/${slug}?${params}`); - } - } else { - router.push(`${prefixUrl}?${params}`); - } + const onPriceFromInput = (e) => { + setPriceFrom(e.target.value); }; - /*const handleIndexAccordion = async () => { - if (brandValues) { - await setActiveIndeces([...activeIndeces, 0]) - } - if (categoryValues) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 1 : 0]) - } - if (priceRange) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 2 : 1]) - } - if (stock) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 3 : 2]) - } - }*/ + const onPriceToInput = (e) => { + setPriceTo(e.target.value); + }; useEffect(() => { setActiveRange(indexRange); - }, []); + }, []); // init active range + + useEffect(() => { + setBrand( + router.query?.brand + ? String(router.query.brand).split(',').filter(Boolean) + : [] + ); + setCategory( + router.query?.category + ? String(router.query.category).split(',').filter(Boolean) + : [] + ); + setPriceFrom(router.query?.priceFrom ?? ''); + setPriceTo(router.query?.priceTo ?? ''); + setStock(router.query?.stock ?? null); + setOrder(router.query?.orderBy ?? ''); + }, [router.query]); return ( <> @@ -159,23 +221,24 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {brands && brands.length > 0 ? ( - brands.map((brand, index) => ( - <div className='flex items-center gap-2 ' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {normBrands.length > 0 ? ( + normBrands.map((b) => ( + <div className='flex items-center gap-2' key={b.id}> <Checkbox - isChecked={brandValues.includes(brand.brand)} + isChecked={brandValues.includes(String(b.id))} onChange={handleBrandsChange} - value={brand.brand} + value={String(b.id)} // idealnya ID brand size='md' > <div className='flex items-center gap-2'> - <span>{brand.brand} </span> - <span className='text-sm text-gray-600'> - ({brand.qty}) - </span> + <span>{b.name}</span> + {b.qty !== undefined && ( + <span className='text-sm text-gray-600'> + ({b.qty}) + </span> + )} </div> </Checkbox> </div> @@ -197,23 +260,20 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {categories && categories.length > 0 ? ( - categories.map((category, index) => ( - <div className='flex items-center gap-2' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {(categories ?? []).length > 0 ? ( + categories.map((c, i) => ( + <div className='flex items-center gap-2' key={i}> <Checkbox - isChecked={categoryValues.includes(category.name)} + isChecked={categoryValues.includes(c.name)} onChange={handleCategoriesChange} - value={category.name} + value={c.name} size='md' > <div className='flex items-center gap-2'> - <span>{category.name} </span> - <span className='text-sm text-gray-600'> - ({category.qty}) - </span> + <span>{c.name}</span> + <span className='text-sm text-gray-600'>({c.qty})</span> </div> </Checkbox> </div> @@ -234,7 +294,6 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <VStack gap={4}> <InputGroup> @@ -243,32 +302,34 @@ const ProductFilterDesktop = ({ type='number' placeholder='Harga minimum' value={priceFrom} - onChange={(e) => setPriceFrom(e.target.value)} + onChange={onPriceFromInput} + onKeyDown={handlePriceKeyDown} // âźµ apply saat Enter /> </InputGroup> + <InputGroup> <InputLeftAddon>Rp</InputLeftAddon> <Input type='number' - placeholder='Harga maximum' + placeholder='Harga maksimum' value={priceTo} - onChange={(e) => setPriceTo(e.target.value)} + onChange={onPriceToInput} + onKeyDown={handlePriceKeyDown} // âźµ apply saat Enter /> </InputGroup> + <div className='grid grid-cols-2 gap-x-3 gap-y-2'> - {priceRange.map((price, i) => ( + {priceRange.map((p, i) => ( <button key={i} onClick={() => - handlePriceFromChange(price.priceFrom, price.priceTo, i) + handlePriceRangeClick(p.priceFrom, p.priceTo, i) } className={`w-full border ${ i === activeRange ? 'border-red-600' : 'border-gray-400' - } - py-2 p-3 rounded-full text-sm whitespace-nowrap`} + } py-2 p-3 rounded-full text-sm whitespace-nowrap`} > - {formatCurrency(price.priceFrom)} -{' '} - {formatCurrency(price.priceTo)} + {formatCurrency(p.priceFrom)} - {formatCurrency(p.priceTo)} </button> ))} </div> @@ -279,27 +340,22 @@ const ProductFilterDesktop = ({ {/* <AccordionItem> <AccordionButton padding={[2, 4]}> <Box as='span' flex='1' textAlign='left' fontWeight='semibold'> - Ketersedian Stok + Ketersediaan Stok </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <Checkbox isChecked={stock === 'ready stock'} onChange={handleReadyStockChange} - value={'ready stock'} + value='ready stock' size='md' > - Ketersedian Stock + Ready Stock </Checkbox> </AccordionPanel> </AccordionItem> */} </Accordion> - - <Button className='w-full mt-6' colorScheme='red' onClick={handleSubmit}> - Terapkan - </Button> </> ); }; diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index 2fb3138a..850d00cc 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -1,12 +1,12 @@ import NextImage from 'next/image'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; import { HStack, Image, Tag, TagCloseButton, TagLabel } from '@chakra-ui/react'; import axios from 'axios'; import _ from 'lodash'; import { toQuery } from 'lodash-contrib'; - +import { FunnelIcon, AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline'; import odooApi from '@/core/api/odooApi'; import searchSpellApi from '@/core/api/searchSpellApi'; import Link from '@/core/components/elements/Link/Link'; @@ -50,7 +50,33 @@ const ProductSearch = ({ const categoryId = getIdFromSlug(prefixUrl); const [data, setData] = useState([]); const [dataLob, setDataLob] = useState([]); + const appliedDefaultBrandOrder = useRef(false); + if (defaultBrand) query.brand = defaultBrand.toLowerCase(); + useEffect(() => { + if (!router.isReady) return; + + const onBrandsPage = router.pathname.includes('brands'); + const hasOrder = typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; + + if (onBrandsPage && !hasOrder && !appliedDefaultBrandOrder.current) { + let params = { + ...router.query, + orderBy: 'popular', + }; + params = _.pickBy(params, _.identity); + const qs = toQuery(params); + + // ganti URL tanpa nambah history & tanpa full reload + router.replace(`${prefixUrl}?${qs}`, undefined, { shallow: true }); + + // sinkronkan state lokal + setOrderBy('popular'); + + appliedDefaultBrandOrder.current = true; + } + }, [router.isReady, router.pathname, router.query?.orderBy, prefixUrl]); + const dataIdCategories = []; useEffect(() => { if (prefixUrl.includes('category')) { @@ -84,7 +110,7 @@ const ProductSearch = ({ if (router.asPath.includes('penawaran')) { query = { ...query, - fq:`flashsale_id_i:${router.query.penawaran} AND flashsale_price_f:[1 TO *]`, + fq: `flashsale_id_i:${router.query.penawaran} AND flashsale_price_f:[1 TO *]`, orderBy: 'flashsale-discount-desc', }; setFinalQuery(query); @@ -404,9 +430,7 @@ const ProductSearch = ({ <div className='p-4 pt-0'> {isNotReadyStockPage && isBrand && isBrand.logo && ( <div className='mb-3'> - <h1 className='mb-2 font-semibold text-h-sm'> - Brand Pencarian {q} - </h1> + <h1 className='mb-2 font-semibold text-h-sm'>Brand Pencarian {q}</h1> <Link href={createSlug('/shop/brands/', isBrand.name, isBrand.id)} className='inline' @@ -419,7 +443,9 @@ const ProductSearch = ({ </Link> </div> )} + <h1 className='mb-2 font-semibold text-h-sm'>Produk</h1> + <FilterChoicesComponent brandValues={brandValues} categoryValues={categoryValues} @@ -428,6 +454,7 @@ const ProductSearch = ({ handleDeleteFilter={handleDeleteFilter} /> + {/* info jumlah hasil */} <div className='mb-2 leading-6 text-gray_r-11'> {!spellings ? ( <> @@ -435,8 +462,7 @@ const ProductSearch = ({ {pageCount > 1 ? ( <> {productStart + 1}- - {parseInt(productStart) + parseInt(productRows) > - productFound + {parseInt(productStart) + parseInt(productRows) > productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -448,8 +474,7 @@ const ProductSearch = ({ produk{' '} {query.q && ( <> - untuk pencarian{' '} - <span className='font-semibold'>{query.q}</span> + untuk pencarian <span className='font-semibold'>{query.q}</span> </> )} </> @@ -457,37 +482,37 @@ const ProductSearch = ({ SpellingComponent )} </div> - <LobSectionCategory categories={dataLob} /> - <CategorySection categories={dataCategories} /> {productFound > 0 && ( - <div className='flex items-center gap-x-2 mb-5 justify-between'> + <div className='flex items-center gap-x-2 mt-2 mb-3 justify-end'> <div> <button - className='btn-light py-2 px-5 h-[40px]' + aria-label='Filter' + title='Filter' onClick={popup.activate} + className='btn-light w-fit flex items-center justify-center rounded-md' > - Filter + <AdjustmentsHorizontalIcon className='w-5 h-5' /> </button> </div> - <div className=''> + <div> <select name='limit' - className='form-input w-24' + className='form-input w-20' value={router.query?.limit || ''} onChange={(e) => handleLimit(e)} > {numRows.map((option, index) => ( <option key={index} value={option}> - {' '} - {option}{' '} + {option} </option> ))} </select> </div> </div> )} - + {!!dataLob?.length && <LobSectionCategory categories={dataLob} />} + {!!dataCategories?.length && <CategorySection categories={dataCategories} />} <div className='grid grid-cols-2 gap-3'> {products && products.map((product) => ( @@ -499,7 +524,6 @@ const ProductSearch = ({ pageCount={pageCount} currentPage={parseInt(page)} url={`${prefixUrl}?${toQuery(_.omit(query, ['page', 'fq']))}`} - // url={prefixUrl.includes('category') || prefixUrl.includes('lob')? `${prefixUrl}?${toQuery(_.omit(finalQuery, ['page']))}` : `${prefixUrl}?${toQuery(_.omit(query, ['page']))}`} className='mt-6 mb-2' /> @@ -597,7 +621,7 @@ const ProductSearch = ({ <> {productStart + 1}- {parseInt(productStart) + parseInt(productRows) > - productFound + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -673,8 +697,8 @@ const ProductSearch = ({ href={ query?.q ? whatsappUrl('productSearch', { - name: query.q, - }) + name: query.q, + }) : whatsappUrl() } className='text-danger-500' @@ -759,9 +783,9 @@ const FilterChoicesComponent = ({ </Tag> )} {brandValues?.length > 0 || - categoryValues?.length > 0 || - priceFrom || - priceTo ? ( + categoryValues?.length > 0 || + priceFrom || + priceTo ? ( <span> <button className='btn-transparent py-2 px-5 h-[40px] text-red-700' diff --git a/src/lib/quotation/components/Quotation.jsx b/src/lib/quotation/components/Quotation.jsx index f0791512..c7e5b16a 100644 --- a/src/lib/quotation/components/Quotation.jsx +++ b/src/lib/quotation/components/Quotation.jsx @@ -33,7 +33,7 @@ import ExpedisiList from '../../checkout/api/ExpedisiList'; import SectionQuotationExpedition from '@/lib/checkout/components/SectionQuotationExpedition'; import { useQuotation } from '@/lib/checkout/stores/stateQuotation'; -const { checkoutApi } = require('@/lib/checkout/api/checkoutApi'); +const { checkoutApi, checkoutQuotation } = require('@/lib/checkout/api/checkoutApi'); const { getProductsCheckout } = require('@/lib/checkout/api/checkoutApi'); const Quotation = () => { @@ -243,8 +243,8 @@ const Quotation = () => { note_website: note_websiteText, }; - const isSuccess = await checkoutApi({ data }); - + const isSuccess = await checkoutQuotation(data); + if (isSuccess?.id) { for (const product of products) { deleteItemCart({ productId: product.id }); diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx index 77e60dc1..96c89aec 100644 --- a/src/lib/transaction/components/Transaction.jsx +++ b/src/lib/transaction/components/Transaction.jsx @@ -43,13 +43,14 @@ import { gtagPurchase } from '@/core/utils/googleTag'; import { deleteItemCart } from '@/core/utils/cart'; import { downloadInvoice, - downloadTaxInvoice, + // downloadTaxInvoice, // (unused) } from '@/lib/invoice/utils/invoices'; import { Download } from 'lucide-react'; import axios from 'axios'; import InformationSection from '../../treckingAwb/component/InformationSection'; -import { Button } from '@chakra-ui/react'; -import { div } from 'lodash-contrib'; +// import { Button } from '@chakra-ui/react'; // (unused) +// import { div } from 'lodash-contrib'; // (unused) + const Transaction = ({ id }) => { const PPN = process.env.NEXT_PUBLIC_PPN; const router = useRouter(); @@ -73,6 +74,7 @@ const Transaction = ({ id }) => { const [toOthers, setToOthers] = useState(null); const [totalAmount, setTotalAmount] = useState(0); const [totalDiscountAmount, setTotalDiscountAmount] = useState(0); + const [contLoading, setContLoading] = useState(false); useEffect(() => { if (transaction?.data?.products) { @@ -88,6 +90,7 @@ const Transaction = ({ id }) => { setTotalDiscountAmount(calculateTotalDiscountAmount); } }, [transaction.data, transaction.isLoading]); + const submitUploadPo = async () => { const file = poFile.current.files[0]; const name = poNumber.current.value; @@ -127,10 +130,6 @@ const Transaction = ({ id }) => { } } }; - // const ContinueTransaction = () => { - // setContinueNoPo(true); - // checkoutNoPO(); - // }; const closeCancelTransaction = () => setCancelTransaction(false); const closeContinueTransaction = () => setContinueTransaction(false); @@ -138,6 +137,7 @@ const Transaction = ({ id }) => { const openRejectTransaction = () => setRejectTransaction(true); const closeRejectTransaction = () => setRejectTransaction(false); + const submitCancelTransaction = async () => { const isCancelled = await cancelTransactionApi({ transaction: transaction.data, @@ -148,6 +148,7 @@ const Transaction = ({ id }) => { } closeCancelTransaction(); }; + const checkout = async () => { if (!transaction.data?.purchaseOrderFile) { toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan'); @@ -194,25 +195,6 @@ const Transaction = ({ id }) => { } toast.success('Berhasil melanjutkan pesanan'); transaction.refetch(); - // console.log(transaction); - - /* const midtrans = async () => { - for (const product of products) deleteItemCart({ productId: product.id }); - if (grandTotal > 0) { - const payment = await axios.post( - `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/midtrans-payment?transactionId=${isCheckouted.id}` - ); - setIsLoading(false); - window.location.href = payment.data.redirectUrl; - } else { - window.location.href = `${ - process.env.NEXT_PUBLIC_SELF_HOST - }/shop/checkout/success?order_id=${isCheckouted.name.replace( - /\//g, - '-' - )}`; - } - };*/ }; const handleApproval = async () => { @@ -227,6 +209,73 @@ const Transaction = ({ id }) => { transaction.refetch(); }; + // ===== Bayar Sekarang (pakai link dari backend; fallback generate via Next API) ===== + const handlePayNow = async () => { + try { + setContLoading(true); + + const base = (process.env.NEXT_PUBLIC_ODOO_API_HOST || '').replace( + /\/$/, + '' + ); + const token = auth?.token; + const partnerId = auth?.partnerId; + + // 1) Minta Odoo ensure payment link + const { data: resp } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${transaction.data.id}`, + { + params: { ensure_payment_link: 1, ts: Date.now() }, + headers: { Token: token }, + } + ); + + // console.log('API Response:', resp); // Debug + + // 2) Akses semua kemungkinan path + let url = + resp?.result?.payment_summary?.redirect_url || + resp?.data?.result?.payment_summary?.redirect_url || + resp?.payment_summary?.redirect_url || + resp?.paymentSummary?.redirectUrl || + ''; + + // console.log('Extracted URL:', url); // Debug + + if (url) { + window.location.href = url; + return; + } + + // 3) Fallback + await transaction.refetch(); + // console.log('Transaction data:', transaction.data); // Debug + + url = + transaction?.data?.result?.payment_summary?.redirect_url || + transaction?.data?.paymentSummary?.redirectUrl || + transaction?.data?.payment_summary?.redirect_url || + ''; + + // console.log('Fallback URL:', url); // Debug + + if (url) { + window.location.href = url; + return; + } + + throw new Error('Link pembayaran belum tersedia.'); + } catch (e) { + toast.error( + e?.response?.data?.description || + e?.message || + 'Gagal membuka pembayaran' + ); + } finally { + setContLoading(false); + } + }; + const memoizeVariantGroupCard = useMemo( () => ( <div className='p-4 pt-0 flex flex-col gap-y-3'> @@ -314,7 +363,7 @@ const Transaction = ({ id }) => { navigator.clipboard.writeText(textToCopy); setCopied(true); toast.success('No Resi Berhasil di Copy'); - setTimeout(() => setCopied(false), 2000); // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); }; const formatDate = (dateString) => { @@ -336,7 +385,7 @@ const Transaction = ({ id }) => { const [day, month, year] = dateString.split('/'); return `${day} ${months[parseInt(month, 10) - 1]} ${year}`; }; - // console.log(transaction); + return ( transaction.data?.name && ( <> @@ -366,6 +415,7 @@ const Transaction = ({ id }) => { </button> </div> </BottomPopup> + <BottomPopup active={cancelTransaction} close={closeCancelTransaction} @@ -452,48 +502,53 @@ const Transaction = ({ id }) => { active={toOthers} close={() => setToOthers(null)} > - <div className='flex flex-col gap-y-4 mt-2'> + {transaction.data?.status === 'draft' && ( + <> <button className='text-left disabled:opacity-60' - disabled={!toOthers?.purchaseOrderFile} + disabled={toOthers?.status != 'draft'} onClick={() => { - downloadPurchaseOrder(toOthers); + downloadQuotation(toOthers); setToOthers(null); }} > - Download PO + Download Quotation </button> <button className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'draft'} + disabled={toOthers?.status != 'waiting'} onClick={() => { - downloadQuotation(toOthers); + setToCancel(toOthers); setToOthers(null); }} > - Download Quotation + Batalkan Transaksi </button> + </> + )} + <div className='flex flex-col gap-y-4 mt-2'> <button className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'waiting'} + disabled={!toOthers?.purchaseOrderFile} onClick={() => { - setToCancel(toOthers); + downloadPurchaseOrder(toOthers); setToOthers(null); }} > - Batalkan Transaksi + Download PO </button> </div> </BottomPopup> <Manifest idAWB={idAWB} closePopup={closePopup}></Manifest> + {/* ============ MOBILE ============ */} <MobileView> <div className='px-4'> <div className='flex flex-row w-full justify-between items-center py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50 gap-2'> - <div class='flex items-center w-full ' role='alert'> + <div className='flex items-center w-full ' role='alert'> <svg - class='flex-shrink-0 inline w-4 h-4 mr-2' + className='flex-shrink-0 inline w-4 h-4 mr-2' aria-hidden='true' fill='currentColor' viewBox='0 0 20 20' @@ -514,6 +569,7 @@ const Transaction = ({ id }) => { </span> </div> </div> + {auth?.feature?.soApproval && ( <div className='p-4'> <StepApproval @@ -559,7 +615,7 @@ const Transaction = ({ id }) => { <Divider /> <div className='flex flex-col gap-y-4 p-4'> - <h4 className="font-semibold">Detail Order</h4> + <h4 className='font-semibold'>Detail Order</h4> <DescriptionRow label='No Transaksi'> <p className='font-semibold'>{transaction.data?.name}</p> </DescriptionRow> @@ -579,9 +635,11 @@ const Transaction = ({ id }) => { <Divider /> <div className='flex flex-col gap-y-4 p-4'> - <h4 className="font-semibold">Alamat Pengiriman</h4> + <h4 className='font-semibold'>Alamat Pengiriman</h4> <DescriptionRow label='Nama Penerima'> - <p className='font-semibold'>{transaction?.data?.address?.customer?.name}</p> + <p className='font-semibold'> + {transaction?.data?.address?.customer?.name} + </p> </DescriptionRow> <DescriptionRow label='No. Telp'> {transaction?.data?.address?.customer?.phone @@ -602,9 +660,7 @@ const Transaction = ({ id }) => { <div className='p-4'> <div className='font-medium mb-4'>Info Pengiriman</div> {transaction?.data?.pickings.length == 0 && ( - <div className='badge-red text-sm'> - Belum ada pengiriman - </div> + <div className='badge-red text-sm'>Belum ada pengiriman</div> )} {transaction?.data?.pickings?.map((airway) => ( <div @@ -627,28 +683,7 @@ const Transaction = ({ id }) => { </button> </div> </div> - // <button - // className='shadow rounded-md p-3 text-gray_r-12 font-normal flex justify-between items-center text-left h-20' - // key={airway?.id} - // onClick={() => setIdAWB(airway?.id)} - // > - // <div> - // <span className='text-sm text-gray_r-11'> - // No Resi : {airway?.trackingNumber || '-'}{' '} - // </span> - // <p className='mt-1 font-medium'>{airway?.name}</p> - // </div> - // <div className='flex gap-x-2'> - // <div className='text-sm text-gray-600 badge-green leading-[1.5] mt-1'> - // {airway?.delivered - // ? 'Pesanan Tiba' - // : 'Sedang Dikirim'} - // </div> - // <ChevronRightIcon className='w-5 stroke-2' /> - // </div> - // </button> ))} - </div> <Divider /> @@ -717,17 +752,20 @@ const Transaction = ({ id }) => { <div className='font-medium p-4'>Detail Produk</div> {transaction?.data?.products.length > 0 ? ( <div className='p-4 pt-0 flex flex-col gap-y-3'> - <VariantGroupCard variants={transaction.data?.products}/> + <VariantGroupCard variants={transaction.data?.products} /> <div className='font-medium'>Rincian Pembayaran</div> <div className='flex justify-between mt-1'> <p className='text-gray_r-12/70'>Metode Pembayaran</p> - <p> - {transaction.data?.paymentTerm || '-'} - </p> + <p>{transaction.data?.paymentTerm || '-'}</p> </div> <div className='flex justify-between mt-1'> <p className='text-gray_r-12/70'>Berat Barang</p> - <p>{(transaction.data?.products?.reduce((total, item) => total + (item.weight || 0), 0)) + ' Kg'}</p> + <p> + {transaction.data?.products?.reduce( + (total, item) => total + (item.weight || 0), + 0 + ) + ' Kg'} + </p> </div> <hr className='mt-1 border border-gray-100' /> <div className='flex justify-between mt-1'> @@ -774,12 +812,7 @@ const Transaction = ({ id }) => { </div> )} - {/* <Divider /> */} - - {/* <SectionAddress address={transaction.data?.address} /> */} - - {/* <Divider /> */} - + {/* Tombol aksi (Mobile) */} {transaction.data?.status === 'draft' && ( <div className='p-4 pt-0'> <button @@ -796,18 +829,33 @@ const Transaction = ({ id }) => { Batalkan Transaksi </button> {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( - <button - className='btn-yellow w-full mt-4' - onClick={openContinueTransaction} - > - Lanjutkan Transaksi - </button> - )} + transaction?.data?.purchaseOrderFile && ( + <button + className='btn-yellow w-full mt-4' + onClick={openContinueTransaction} + > + Lanjutkan Transaksi + </button> + )} + </div> + )} + + {/* Bayar Sekarang (Mobile) — tampil jika eligible */} + {transaction.data?.eligibleContinue && ( + <div className='p-4 pt-0'> + <button + type='button' + disabled={contLoading} + onClick={handlePayNow} + className='w-full py-2 text-center rounded-md border border-red-500 text-red-500 bg-white disabled:opacity-60' + > + {contLoading ? 'Memproses…' : 'Bayar Sekarang'} + </button> </div> )} </MobileView> + {/* ============ DESKTOP ============ */} <DesktopView> <div className='container mx-auto flex py-10'> <div className='w-3/12 pr-4'> @@ -827,44 +875,36 @@ const Transaction = ({ id }) => { )} </div> - {/*new-release*/} - {/*<div className='flex items-center justify-between mb-3'>*/} - {/* <div className='flex items-center gap-x-2'>*/} - {/* <span className='text-h-sm font-medium'>*/} - {/* {transaction?.data?.name}*/} - {/* </span>*/} - {/* <TransactionStatusBadge status={transaction?.data?.status} />*/} - {/* </div>*/} - {/* <div className='text-h-sm'>*/} - {/* Estimasi Barang Siap:{' '}*/} - {/* <span className='text-red-500 font-semibold'>*/} - {/* {transaction?.data?.expectedReadyToShip}*/} - {/* </span>*/} - {/* </div>*/} - <div className='flex items-center gap-x-2 mb-3'> - <span className='text-h-sm font-medium'> - {transaction?.data?.name} - </span> - <TransactionStatusBadge status={transaction?.data?.status} /> - {transaction.data?.status === 'draft' && ( - <div className='flex items-center justify-between w-full'> - <button - type='button' - className='btn-light px-3 py-2' - onClick={() => downloadQuotation(transaction.data)} - > - <Download size={12} /> - </button> + {/* HEADER (Desktop) — sejajarkan kiri & kanan */} + <div className='flex items-center justify-between gap-3 mb-3'> + {/* Kiri: SO + badge */} + <div className='flex items-center gap-x-2 min-w-0'> + <span className='text-h-sm font-medium truncate'> + {transaction?.data?.name} + </span> + <TransactionStatusBadge status={transaction?.data?.status} /> + </div> + + {/* Kanan: aksi */} + <div className='flex items-center gap-3'> + {transaction.data?.status === 'draft' && ( + <> + <button + type='button' + className='btn-light px-3 py-2' + onClick={() => downloadQuotation(transaction.data)} + > + <Download size={12} /> + </button> - <div className="flex gap-x-4"> <button className='btn-solid-red' onClick={openCancelTransaction} > Batalkan Transaksi </button> - {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( + + {transaction?.data?.purchaseOrderFile && ( <button className='btn-yellow' onClick={openContinueTransaction} @@ -872,37 +912,20 @@ const Transaction = ({ id }) => { Lanjutkan Transaksi </button> )} - </div> - </div> - )} - </div> - {/* {transaction.data?.status === 'draft' && ( - <div className='flex gap-x-4'> - <button - type='button' - className='btn-light px-3 py-2 mr-auto' - onClick={() => downloadQuotation(transaction.data)} - > - <Download size={12} /> - </button> - <button - className='btn-solid-red' - onClick={openCancelTransaction} - > - Batalkan Transaksi - </button> - - {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( - <button - className='btn-yellow' - onClick={openContinueTransaction} - > - Lanjutkan Transaksi - </button> - )} + </> + )} + + {transaction.data?.eligibleContinue && ( + <button + className='px-4 py-2 rounded-md border border-red-500 text-red-500 bg-white disabled:opacity-60 mb-3' + disabled={contLoading} + onClick={handlePayNow} + > + {contLoading ? 'Memproses…' : 'Bayar Sekarang'} + </button> + )} </div> - )} */} + </div> <div className='grid grid-cols-2 gap-x-6 mt-4'> <div className='grid grid-cols-[35%_65%] gap-y-4'> @@ -967,29 +990,13 @@ const Transaction = ({ id }) => { key={index} > {invoice?.name} - {/* <div className='shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between'> - <div> - <p className='mb-1'>{invoice?.name}</p> - <div className='flex items-center gap-x-1'> - {invoice.amountResidual > 0 ? ( - <div className='badge-red'>Belum Lunas</div> - ) : ( - <div className='badge-green'>Lunas</div> - )} - <p className='text-caption-2 text-gray_r-11'> - {currencyFormat(invoice.amountTotal)} - </p> - </div> - </div> - <ChevronRightIcon className='w-5 stroke-2' /> - </div> */} </Link> ))} </div> </div> </div> <hr className='mt-4 mb-4 border border-gray-100' /> - {/* <div className='grid grid-cols-2 gap-x-6'> */} + <div className='flex flex-row justify-between items-start w-full h-fit '> <div className='flex flex-col w-1/2 justify-start items-start'> <span className='text-h-sm font-medium mb-2'> @@ -1039,17 +1046,17 @@ const Transaction = ({ id }) => { ) : ( '-' )} - {transaction?.data?.carrierId !== 32 &&( - <> - <div>Jenis Service</div> - <div>: </div> - <div> - {' '} - {transaction?.data?.serviceType - ? transaction?.data?.serviceType - : '-'} - </div> - </> + {transaction?.data?.carrierId !== 32 && ( + <> + <div>Jenis Service</div> + <div>: </div> + <div> + {' '} + {transaction?.data?.serviceType + ? transaction?.data?.serviceType + : '-'} + </div> + </> )} <div>Estimasi Tanggal Kirim</div> @@ -1059,41 +1066,42 @@ const Transaction = ({ id }) => { ? transaction?.data?.expectedReadyToShip : '-'} </div> - {transaction?.data?.carrierId !== 32 &&( - <> - <div>Estimasi Tiba</div> - <div>: </div> - <div className=''> - {transaction?.data?.etaDateStart && transaction?.data?.etaDateEnd ? ( - `${transaction.data.etaDateStart} - ${transaction.data.etaDateEnd}` - ) : ( - '-' - )} - </div> - </> + {transaction?.data?.carrierId !== 32 && ( + <> + <div>Estimasi Tiba</div> + <div>: </div> + <div className=''> + {transaction?.data?.etaDateStart && + transaction?.data?.etaDateEnd + ? `${transaction.data.etaDateStart} - ${transaction.data.etaDateEnd}` + : '-'} + </div> + </> )} - {transaction?.data?.pickings[0] && transaction?.data?.carrierId !== 32 && ( - <div className='w-full bagian-informasi col-span-3'> - <div - class='flex items-center w-fit py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' - role='alert' - > - <svg - class='flex-shrink-0 inline w-4 h-4 mr-2' - aria-hidden='true' - fill='currentColor' - viewBox='0 0 20 20' + {transaction?.data?.pickings[0] && + transaction?.data?.carrierId !== 32 && ( + <div className='w-full bagian-informasi col-span-3'> + <div + className='flex items-center w-fit py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' + role='alert' > - <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> - </svg> - <div className='text-justify flex flex-col gap-1'> - <span className='text-black text-xs'> - Pesanan anda mungkin mengalami keterlambatan tiba - </span> + <svg + className='flex-shrink-0 inline w-4 h-4 mr-2' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 20 20' + > + <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> + </svg> + <div className='text-justify flex flex-col gap-1'> + <span className='text-black text-xs'> + Pesanan anda mungkin mengalami keterlambatan + tiba + </span> + </div> </div> </div> - </div> - )} + )} </div> </div> </div> @@ -1103,9 +1111,7 @@ const Transaction = ({ id }) => { </div> <div className='grid grid-cols-1 md:grid-cols-2 gap-3'> {transaction?.data?.pickings.length == 0 && ( - <div className='badge-red text-sm'> - Belum ada pengiriman - </div> + <div className='badge-red text-sm'>Belum ada pengiriman</div> )} {transaction?.data?.pickings?.map((airway) => ( <div @@ -1129,39 +1135,9 @@ const Transaction = ({ id }) => { </div> </div> ))} - {/* </div> */} - </div> <div className='flex '> - {/*New release*/} - {/* <div className='grid grid-cols-1 gap-1 w-2/3'>*/} - {/* {transaction?.data?.pickings?.map((airway) => (*/} - {/* <button*/} - {/* className='shadow rounded-md p-3 text-gray_r-12 font-normal flex justify-between items-center text-left h-20'*/} - {/* key={airway?.id}*/} - {/* onClick={() => setIdAWB(airway?.id)}*/} - {/* >*/} - {/* <div>*/} - {/* <p className='text-sm text-gray_r-11'>*/} - {/* {airway?.name}*/} - {/* </p>*/} - {/* <span className='text-md text-bold mt-1'>*/} - {/* No Resi : {airway?.trackingNumber || '-'}{' '}*/} - {/* </span>*/} - {/* </div>*/} - {/* <div className='flex gap-x-2'>*/} - {/* <div className='text-sm text-gray-600 badge-green leading-[1.5] mt-1 text-center'>*/} - {/* {airway?.delivered*/} - {/* ? 'Pesanan Tiba'*/} - {/* : 'Sedang Dikirim'}*/} - {/* </div>*/} - {/* <ChevronRightIcon className='w-5 stroke-2' />*/} - {/* </div>*/} - {/* </button>*/} - {/* ))}*/} - {/* </div>*/} - {/*</div>*/} <div className='invoice w-1/2 '> <div className='text-h-sm font-semibold mt-10 mb-4 '> Invoice @@ -1202,7 +1178,6 @@ const Transaction = ({ id }) => { <thead> <tr> <th>Nama Produk</th> - {/* <th>Diskon</th> */} <th>Jumlah</th> <th>Harga</th> <th>Subtotal</th> @@ -1280,24 +1255,13 @@ const Transaction = ({ id }) => { )} </div> </td> - {/* <td> - {product.price.discountPercentage > 0 - ? `${product.price.discountPercentage}%` - : ''} - </td> */} <td>{product.quantity}</td> <td> - {/* {product.price.discountPercentage > 0 && ( - <div className='line-through mb-1 text-caption-1 text-gray_r-12/70'> - {currencyFormat(product.price.price)} - </div> - )} */} <div> {currencyFormat(product.price.priceDiscount)} </div> </td> <td>{currencyFormat(product.price.subtotal)}</td> - {/* {auth?.feature.soApproval && (auth.webRole == 2 || auth.webRole == 3) && (transaction.data.isReaject == false) && ( */} {auth?.feature.soApproval && (auth.webRole == 2 || auth.webRole == 3) && router.asPath.includes('/my/quotations/') && @@ -1354,34 +1318,6 @@ const Transaction = ({ id }) => { )} {transaction?.data?.products?.length > 0 && ( - // <div className='flex justify-end mt-4'> - // <div className='w-1/4 grid grid-cols-2 gap-y-3 text-gray_r-12/80'> - // <div className='text-right'>Subtotal</div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.amountUntaxed)} - // </div> - - // <div className='text-right'> - // PPN {((PPN - 1) * 100).toFixed(0)}% - // </div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.amountTax)} - // </div> - - // <div className='text-right whitespace-nowrap'> - // Biaya Pengiriman - // </div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.deliveryAmount)} - // </div> - - // <div className='text-right'>Grand Total</div> - // <div className='text-right font-medium text-gray_r-12'> - // {currencyFormat(transaction.data?.amountTotal)} - // </div> - // </div> - // </div> - <div className='flex justify-end mt-4 flex-col items-end'> <div className='w-1/4 grid grid-cols-2 gap-y-3 text-gray_r-12/80'> <div className='text-right'>Total Belanja</div> @@ -1437,7 +1373,6 @@ const Transaction = ({ id }) => { <thead> <tr> <th>Nama Produk</th> - {/* <th>Diskon</th> */} <th>Jumlah</th> <th>Harga</th> <th>Subtotal</th> @@ -1480,18 +1415,8 @@ const Transaction = ({ id }) => { </div> </div> </td> - {/* <td> - {product.price.discountPercentage > 0 - ? `${product.price.discountPercentage}%` - : ''} - </td> */} <td>{product.quantity}</td> <td> - {/* {product.price.discountPercentage > 0 && ( - <div className='line-through mb-1 text-caption-1 text-gray_r-12/70'> - {currencyFormat(product.price.price)} - </div> - )} */} <div> {currencyFormat(product.price.priceDiscount)} </div> @@ -1512,58 +1437,6 @@ const Transaction = ({ id }) => { </div> </div> </DesktopView> - - {/* {queryAirwayBill.data?.airways?.map((airway) => ( - <BottomPopup - key={airway.waybillNumber} - title='Detail Pengiriman' - active={airwayBillPopup == airway.waybillNumber} - close={() => setAirwayBillPopup(null)} - > - <div className='flex flex-col gap-y-4 my-4'> - <div className='flex justify-between'> - <div className='text-gray_r-11'>No Pengiriman</div> - <div>{airway?.deliveryOrder?.name}</div> - </div> - <div className='flex justify-between'> - <div className='text-gray_r-11'>Kurir</div> - <div>{airway?.deliveryOrder?.carrier}</div> - </div> - <div className='flex justify-between'> - <div className='text-gray_r-11'>No Resi</div> - <div>{airway?.waybillNumber}</div> - </div> - </div> - - <div className='pt-4'> - <div className='font-semibold text-body-1 mb-4'>Status Pengiriman</div> - <ol class='relative border-l border-gray_r-7'> - {airway?.manifests?.map((manifest, index) => ( - <li class='mb-6 ml-4' key={index}> - <div - class={`absolute w-3 h-3 rounded-full mt-1.5 -left-1.5 border ${ - index == 0 ? 'bg-red-600 border-red-600' : 'bg-gray_r-7 border-white' - }`} - /> - <time class='text-sm leading-none text-gray-400'> - {manifest.datetime} - </time> - <p - class={`leading-6 font-medium text-body-2 mt-2 ${ - index == 0 ? 'text-red-600' : 'text-gray_r-11' - }`} - > - {manifest.description} - </p> - </li> - ))} - {(!airway?.manifests || airway?.manifests?.length == 0) && ( - <div className='badge-red text-sm'>Belum ada pengiriman</div> - )} - </ol> - </div> - </BottomPopup> - ))} */} </> ) ); @@ -1589,24 +1462,7 @@ const SectionAddress = ({ address }) => { {section.customer && <SectionContent address={address?.customer} />} - {/* <Divider /> - - <SectionButton - label='Detail Pengiriman' - active={section.shipping} - toggle={() => toggleSection('shipping')} - /> - - {section.shipping && <SectionContent address={address?.shipping} />} - - <Divider /> - - <SectionButton - label='Detail Penagihan' - active={section.invoice} - toggle={() => toggleSection('invoice')} - /> - {section.invoice && <SectionContent address={address?.invoice} />} */} + {/* Bagian shipping/invoice disembunyikan */} </> ); }; diff --git a/src/lib/transaction/components/TransactionStatusBadge.jsx b/src/lib/transaction/components/TransactionStatusBadge.jsx index cb8cbcd9..d23b17cd 100644 --- a/src/lib/transaction/components/TransactionStatusBadge.jsx +++ b/src/lib/transaction/components/TransactionStatusBadge.jsx @@ -4,6 +4,10 @@ const TransactionStatusBadge = ({ status }) => { text: '' } switch (status) { + case 'belum_bayar': + badgeProps.className.push('badge-solid-red') + badgeProps.text = 'Belum Bayar' + break case 'cancel': badgeProps.className.push('badge-solid-red') badgeProps.text = 'Pesanan Batal' diff --git a/src/lib/transaction/components/Transactions.jsx b/src/lib/transaction/components/Transactions.jsx index 5e37be50..600518fa 100644 --- a/src/lib/transaction/components/Transactions.jsx +++ b/src/lib/transaction/components/Transactions.jsx @@ -1,11 +1,11 @@ +import axios from 'axios'; import { useRouter } from 'next/router'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { toast } from 'react-hot-toast'; import { EllipsisVerticalIcon, MagnifyingGlassIcon, ChevronDownIcon, - ChevronUpIcon, } from '@heroicons/react/24/outline'; import useAuth from '@/core/hooks/useAuth'; import { @@ -38,17 +38,13 @@ import { Navigation } from 'swiper'; import 'swiper/css'; import 'swiper/css/navigation'; import { Calendar } from 'lucide-react'; -import DatePicker from 'react-datepicker'; -import 'react-datepicker/dist/react-datepicker.css'; import { DateRangePicker } from 'react-date-range'; -import { addDays } from 'date-fns'; -import 'react-date-range/dist/styles.css'; // main style file -import 'react-date-range/dist/theme/default.css'; // theme css file -import { Popover } from '@headlessui/react'; +import 'react-date-range/dist/styles.css'; +import 'react-date-range/dist/theme/default.css'; + const Transactions = ({ context = '' }) => { const auth = useAuth(); const router = useRouter(); - const swiperRef = useRef(null); const { q = '', page = 1, @@ -59,15 +55,11 @@ const Transactions = ({ context = '' }) => { startDate = null, endDate = new Date(), } = router.query; - const { - productCart, - setRefreshCart, - setProductCart, - refreshCart, - isLoading, - setIsloading, - } = useProductCartContext(); + const { setRefreshCart } = useProductCartContext(); + const [inputQuery, setInputQuery] = useState(q); + const [cachedAllData, setCachedAllData] = useState(null); // Simpan data "All" + const [currentData, setCurrentData] = useState([]); // Data yang ditampilkan const [toOthers, setToOthers] = useState(null); const [toCancel, setToCancel] = useState(null); const [listSites, setListSites] = useState([]); @@ -75,17 +67,16 @@ const Transactions = ({ context = '' }) => { const [siteFilter, setSiteFilter] = useState(site); const [pageNew, setPageNew] = useState(page); const [limitNew, setLimitNew] = useState(limit); - // const [status, setStatus] = useState('idle'); const [statusNew, setStatusNew] = useState(status); const [sortNew, setSortNew] = useState(sort); const [contextNew, setcontextNew] = useState(router.query.context || context); - const [dateRange, setDateRange] = useState([null, null]); - // const [startDate, endDate] = dateRange; const [isOpenCalender, setIsOpenCalender] = useState(false); - const [cachedAllData, setCachedAllData] = useState(null); // Simpan data "All" - const [currentData, setCurrentData] = useState([]); // Data yang ditampilkan const calendarRef = useRef(null); - const [isDateSelected, setIsDateSelected] = useState(false); + const isUnpaid = (s) => + ['belum_bayar'].includes(String(s || '').toLowerCase()); + + // loading id utk tombol lanjutkan transaksi + const [contLoadingId, setContLoadingId] = useState(null); const parseDate = (date) => { if (!date || date === 'null') return null; @@ -96,7 +87,7 @@ const Transactions = ({ context = '' }) => { const [state, setState] = useState([ { - startDate: startDate != null || 'null' ? parseDate(startDate) : null, // Gunakan `parseDate` + startDate: startDate != null || 'null' ? parseDate(startDate) : null, endDate: startDate == null ? endDate : parseDate(endDate), key: 'selection', }, @@ -116,9 +107,11 @@ const Transactions = ({ context = '' }) => { site: siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), }; + const statuses = [ { id: 'all', label: 'Semua' }, { id: 'quotation', label: 'Pending Quotation' }, + { id: 'belum_bayar', label: 'Belum Bayar' }, { id: 'diproses', label: 'Pesanan Diproses' }, { id: 'dikemas', label: 'Pesanan Dikemas' }, { id: 'partial', label: 'Dikirim Sebagian' }, @@ -135,6 +128,7 @@ const Transactions = ({ context = '' }) => { shipping: 'Pesanan Dikirim', done: 'Pesanan Selesai', cancel: 'Pesanan Dibatalkan', + belum_bayar: 'Belum Bayar', }; const sortes = [ @@ -142,16 +136,16 @@ const Transactions = ({ context = '' }) => { { id: 'asc', label: 'dari yang terkecil' }, { id: 'desc', label: 'dari yang terbesar' }, ]; + const { transactions } = useTransactions({ query }); + const fetchSite = async () => { const site = await getSite(); setListSites(site.sites); }; const submitCancelTransaction = async () => { - const isCancelled = await cancelTransactionApi({ - transaction: toCancel, - }); + const isCancelled = await cancelTransactionApi({ transaction: toCancel }); if (isCancelled) { toast.success('Berhasil batalkan transaksi'); transactions.refetch(); @@ -159,23 +153,16 @@ const Transactions = ({ context = '' }) => { setToCancel(null); }; - const pageCount = Math.ceil(transactions?.data?.saleOrderTotal / limitNew); - let pageQuery = _.omit(query, ['limit', 'offset', 'context']); - pageQuery = _.pickBy( - pageQuery, - (value, key) => value !== '' && !(key === 'page' && value === '1') + const pageCount = Math.ceil( + (transactions?.data?.saleOrderTotal || 0) / (limitNew || 1) ); - pageQuery = toQuery(pageQuery); const handleSubmit = (e) => { e.preventDefault(); const queryParams = {}; if (inputQuery) queryParams.q = inputQuery; if (siteFilter) queryParams.site = siteFilter; - router.push({ - pathname: router.pathname, - query: queryParams, - }); + router.push({ pathname: router.pathname, query: queryParams }); }; const handleSiteFilterChange = (e) => { @@ -183,10 +170,7 @@ const Transactions = ({ context = '' }) => { const queryParams = {}; if (inputQuery) queryParams.q = inputQuery; if (e.target.value) queryParams.site = e.target.value; - router.push({ - pathname: router.pathname, - query: queryParams, - }); + router.push({ pathname: router.pathname, query: queryParams }); }; const exportToExcel = (data, siteFilter) => { @@ -201,19 +185,17 @@ const Transactions = ({ context = '' }) => { ]; const rowsToExport = []; - data.forEach((saleOrder) => { + (data || []).forEach((saleOrder) => { const row = { 'No. Transaksi': saleOrder.name, 'No. PO': saleOrder.purchaseOrderName || '-', Tanggal: saleOrder.dateOrder || '-', - 'Created By': saleOrder.address.customer?.name || '-', + 'Created By': saleOrder.address?.customer?.name || '-', Salesperson: saleOrder.sales, Total: currencyFormat(saleOrder.amountTotal), Status: contextLabelMap[saleOrder.status] || saleOrder.status, }; - if (siteFilter) { - row['Site'] = siteFilter; - } + if (siteFilter) row['Site'] = siteFilter; rowsToExport.push(row); }); @@ -226,13 +208,30 @@ const Transactions = ({ context = '' }) => { XLSX.writeFile(workbook, 'transactions.xlsx'); }; + const getAllData = async () => { + const qobj = { + name: q, + offset: (pageNew - 1) * limitNew, + limit: limitNew, + context: contextNew[statusNew] || 'all', + sort: sortNew, + startDate: state[0]?.startDate + ? state[0].startDate.toLocaleDateString('id-ID') + : null, + endDate: state[0]?.endDate + ? state[0].endDate.toLocaleDateString('id-ID') + : null, + site: + siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), + }; + const queryString = toQuery(qobj); + const data = await transactionsApi({ query: queryString }); + return data; + }; + const handleExportCSV = async () => { const dataToExport = await getAllData(); - exportToCSV(dataToExport?.saleOrders, siteFilter); - }; - - const exportToCSV = (data, siteFilter) => { const fieldsToExport = [ 'No. Transaksi', 'No. PO', @@ -242,28 +241,23 @@ const Transactions = ({ context = '' }) => { 'Total', 'Status', ]; - - if (siteFilter) { - fieldsToExport.push('Site'); - } - - const rowsToExport = data.map((saleOrder) => { - const row = [ - saleOrder.name, - saleOrder.purchaseOrderName || '-', - saleOrder.dateOrder || '-', - saleOrder.address.customer?.name || '-', - saleOrder.sales, - currencyFormat(saleOrder.amountTotal), - contextLabelMap[saleOrder.status] || saleOrder.status, - ]; - - if (siteFilter) { - row.push(siteFilter); - } - - return row.join(','); - }); + const rowsToExport = + dataToExport?.saleOrders?.map((saleOrder) => { + const row = [ + saleOrder.name, + saleOrder.purchaseOrderName || '-', + saleOrder.dateOrder || '-', + saleOrder.address?.customer?.name || '-', + saleOrder.sales, + currencyFormat(saleOrder.amountTotal), + (contextLabelMap[saleOrder.status] || saleOrder.status || '').replace( + /,/g, + ' ' + ), + ]; + if (siteFilter) row.push((siteFilter || '').replace(/,/g, ' ')); + return row.join(','); + }) || []; const csvContent = 'data:text/csv;charset=utf-8,' + @@ -278,66 +272,31 @@ const Transactions = ({ context = '' }) => { document.body.removeChild(link); }; - const getAllData = async () => { - const query = { - name: q, - offset: (pageNew - 1) * limitNew, - limit: limitNew, - context: contextNew[statusNew] || 'all', - sort: sortNew, - startDate: state[0]?.startDate - ? state[0].startDate.toLocaleDateString('id-ID') - : null, - endDate: state[0]?.endDate - ? state[0].endDate.toLocaleDateString('id-ID') - : null, - site: - siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), - }; - const queryString = toQuery(query); - const data = await transactionsApi({ query: queryString }); - return data; - }; - const handleExportExcel = async () => { const dataToExport = await getAllData(); - exportToExcel(dataToExport?.saleOrders, siteFilter); }; const handleDownload = (format) => { - handleExport(format); + if (format === 'csv') handleExportCSV(); + else if (format === 'xlsx') handleExportExcel(); setIsOpen(false); }; - const handleExport = (format) => { - if (format === 'csv') { - handleExportCSV(); - } else if (format === 'xlsx') { - handleExportExcel(); - } - }; - useEffect(() => { const handleClickOutside = (event) => { - if ( - calendarRef.current && - !calendarRef.current.contains(event.target) - ) { + if (calendarRef.current && !calendarRef.current.contains(event.target)) { setIsOpenCalender(false); } }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const startItem = 1 + (pageNew - 1) * limitNew; const endItem = Math.min( limitNew * pageNew, - transactions?.data?.saleOrderTotal + transactions?.data?.saleOrderTotal || 0 ); useEffect(() => { @@ -346,11 +305,8 @@ const Transactions = ({ context = '' }) => { const handleBuyBack = async (products) => { try { - // setStatus('loading'); - console.log("Products to add:", products); - const results = await Promise.all( - products.map((product) => + (products || []).map((product) => upsertUserCart({ userId: auth.id, type: 'product', @@ -359,51 +315,42 @@ const Transactions = ({ context = '' }) => { selected: true, source: 'add_to_cart', qtyAppend: true, - }).catch(error => { - return { error, product }; - }) + }).catch((error) => ({ error, product })) ) ); - const failedOperations = results.filter(result => result && result.error); - // console.log(results); - - if (failedOperations.length > 0) { - console.error('Some products failed to add to cart:', failedOperations); - toast.error(`${failedOperations.length} produk gagal ditambahkan ke keranjang`); - - // You might want to proceed with the successful ones or handle differently - if (failedOperations.length < products.length) { - toast.success(`${products.length - failedOperations.length} produk berhasil ditambahkan ke keranjang`); + const failed = results.filter((r) => r && r.error); + if (failed.length > 0) { + toast.error(`${failed.length} produk gagal ditambahkan ke keranjang`); + if (failed.length < (products || []).length) { + toast.success( + `${ + (products || []).length - failed.length + } produk berhasil ditambahkan` + ); setRefreshCart(true); router.push('/shop/cart'); } return; } - - // All operations succeeded setRefreshCart(true); - toast.success('Semua produk berhasil ditambahkan ke keranjang belanja'); + toast.success('Semua produk berhasil ditambahkan ke keranjang'); router.push('/shop/cart'); - - } catch (error) { - console.error('Gagal menambahkan produk ke keranjang:', error); + } catch { toast.error('Terjadi kesalahan saat menambahkan produk ke keranjang'); - // setStatus('error'); } }; - const handleStatusChange = async (status) => { setStatusNew(status); setPageNew(1); if (status === 'all' && cachedAllData) { - setCurrentData(cachedAllData); - return; + setCurrentData(cachedAllData); + return; } const data = await fetchSite(status, 1); - + if (status === 'all') { setCachedAllData(data); } @@ -411,19 +358,8 @@ const Transactions = ({ context = '' }) => { setCurrentData(data); }; - useEffect(() => { - setCachedAllData([]); - }, []); - - const handleReset = () => { - setState([ - { - startDate: null, - endDate: new Date(), - key: 'selection', - }, - ]); + setState([{ startDate: null, endDate: new Date(), key: 'selection' }]); setIsOpenCalender(false); router.push(`${router.pathname}`); }; @@ -443,20 +379,95 @@ const Transactions = ({ context = '' }) => { 'November', 'Desember', ]; - - const [day, month, year] = dateString.split('/'); + const [day, month, year] = (dateString || '').split('/'); + if (!day || !month || !year) return dateString || '-'; return `${day} ${months[parseInt(month, 10) - 1]} ${year}`; }; + // ==== Lanjutkan Transaksi (tanpa endpoint baru) ==== + const handleContinuePayment = async (saleOrder) => { + try { + setContLoadingId(saleOrder.id); + + const base = (process.env.NEXT_PUBLIC_ODOO_API_HOST || '').replace( + /\/$/, + '' + ); + const token = auth?.token; + const partnerId = auth?.partnerId; + + // 1. TRIGGER GENERATE + GET URL + const { data: response } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${saleOrder.id}`, + { + params: { ensure_payment_link: 1, ts: Date.now() }, + headers: { Token: token, 'Cache-Control': 'no-cache' }, + timeout: 10000, + } + ); + + // 2. EKSTRAK URL + let paymentUrl = + response?.result?.payment_summary?.redirect_url || + response?.data?.result?.payment_summary?.redirect_url; + + // 3. JIKA DAPAT URL, BUKA + if (paymentUrl) { + window.location.href = paymentUrl; + toast.success('Membuka halaman pembayaran…'); + return; + } + + // 4. FALLBACK: COBA TANPA ensure_payment_link + try { + const { data: fallbackResponse } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${saleOrder.id}`, + { headers: { Token: token }, timeout: 5000 } + ); + + const fallbackUrl = + fallbackResponse?.result?.payment_summary?.redirect_url || + fallbackResponse?.data?.result?.payment_summary?.redirect_url; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + toast.success('Membuka halaman pembayaran…'); + return; + } + } catch (fallbackError) { + // Continue to next fallback + } + + // 5. ULTIMATE FALLBACK: PAKAI URL DARI DATA LAMA + const existingUrl = + saleOrder?.paymentSummary?.redirectUrl || + saleOrder?.payment_summary?.redirect_url; + + if (existingUrl) { + window.open(existingUrl, '_blank', 'noopener,noreferrer'); + toast.success('Membuka halaman pembayaran…'); + } else { + toast.error('Link pembayaran tidak ditemukan. Silakan coba lagi.'); + } + } catch (error) { + toast.error( + error.response?.data?.description || 'Gagal memproses pembayaran' + ); + } finally { + setContLoadingId(null); + } + }; + return ( <> + {/* ===== MOBILE ===== */} <MobileView> - <div className='p-4 flex flex-col gap-y-4'> - <div className='grid grid-cols-[30%_30%_40%] justify-between items-center gap-2 w-full '> + <div className=' flex flex-col gap-y-4'> + <div className='grid grid-cols-[40%_40%_15%] justify-between items-center gap-2 w-full '> <select value={statusNew} onChange={(e) => handleStatusChange(e.target.value)} - className='border border-gray-300 rounded-lg px-2 py-1 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' + className='border border-gray-300 rounded-lg px-2 py-2 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' > {statuses.map((status) => ( <option key={status.id} value={status.id}> @@ -467,7 +478,7 @@ const Transactions = ({ context = '' }) => { <select value={sortNew} onChange={(e) => setSortNew(e.target.value)} - className='border border-gray-300 rounded-lg px-2 py-1 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' + className='border border-gray-300 rounded-lg px-2 py-2 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' > {sortes.map((status) => ( <option key={status.id} value={status.id}> @@ -475,23 +486,22 @@ const Transactions = ({ context = '' }) => { </option> ))} </select> - <div ref={calendarRef} className="relative inline-block"> - <button - type='button' - className='p-2 w-auto h-auto cursor-pointer hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' - onClick={() => setIsOpenCalender((prev) => !prev)} - > - <span className='text-nowrap px-1 truncate flex items-center gap-1'> - {state[0]?.startDate ? ( - `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` - ) : ( - <Calendar size={16} className="text-gray-500" /> - )} - </span> - </button> - {isOpenCalender && ( + <div ref={calendarRef} className='relative inline-block'> + <button + type='button' + className='p-1 w-full h-auto cursor-pointer border hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' + onClick={() => setIsOpenCalender((prev) => !prev)} + > + <span className='text-nowrap px-1 truncate flex items-center gap-1'> + {state[0]?.startDate ? ( + `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` + ) : ( + <Calendar size={20} className='text-gray-500' /> + )} + </span> + </button> + {isOpenCalender && ( <div className='absolute right-1 mt-2 bg-white p-4 rounded shadow-lg z-50'> - {/* Tombol silang di sudut kanan atas */} <button onClick={() => setIsOpenCalender(false)} className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' @@ -508,37 +518,13 @@ const Transactions = ({ context = '' }) => { className='w-full' /> <style>{` - /* Atur container agar menjadi column */ - .rdrCalendarWrapper { - display: flex; - flex-direction: column; - } - .rdrDateRangePickerWrapper { - display: flex; - flex-direction: column; - } - - /* Pindahkan rdrStaticRanges ke atas */ - .rdrDefinedRangesWrapper { - order: -1; - width: fit-content; - } - .rdrStaticRanges { - flex-direction: row; - margin-right: 2px; - } - - /* Sembunyikan bagian input manual */ - .rdrInputRanges { - display: none !important; - } - - .rdrStaticRangeLabel { - padding: 10px 10px; - } - .rdrMonth { - width: -moz-available; - } + .rdrCalendarWrapper{display:flex;flex-direction:column;} + .rdrDateRangePickerWrapper{display:flex;flex-direction:column;} + .rdrDefinedRangesWrapper{order:-1;width:fit-content;} + .rdrStaticRanges{flex-direction:row;margin-right:2px;} + .rdrInputRanges{display:none !important;} + .rdrStaticRangeLabel{padding:10px 10px;} + .rdrMonth{width:-moz-available;} `}</style> <div className='flex flex-row justify-end gap-3 mt-2'> <button @@ -549,68 +535,7 @@ const Transactions = ({ context = '' }) => { </button> </div> </div> - )} - </div> - {/* <div className='border border-gray-300 rounded-lg px-1 py-1 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500 text-xs'> - <DatePicker - closeOnScroll={(e) => e.target === document} - selectsRange={true} - startDate={startDate} - endDate={endDate} - dateFormat='dd/MM' - className='w-full' - maxDate={new Date()} - placeholderText='Semua Tanggal' - onChange={(update) => { - setDateRange(update); - }} - withPortal - isClearable={true} - /> - </div> */} - </div> - <div className='flex flex-row justify-between items-center gap-2'> - <form className='flex' onSubmit={handleSubmit}> - <button - className='btn-light border-r-0 rounded-r-none bg-transparent px-3' - type='submit' - > - <MagnifyingGlassIcon className='w-6' /> - </button> - <input - type='text' - className='form-input border-l-0 rounded-l-none text-xs' - placeholder='Cari Transaksi...' - value={inputQuery} - onChange={(e) => setInputQuery(e.target.value)} - /> - </form> - <div className='flex flex-row gap-2 items-center justify-center text-nowrap'> - <p className='text-xs'> - Menampilkan {startItem}- - {endItem - ? endItem - : transactions?.data?.saleOrderTotal - ? transactions?.data?.saleOrderTotal - : limitNew * pageNew}{' '} - dari{' '} - {transactions?.data?.saleOrderTotal - ? transactions?.data?.saleOrderTotal - : limitNew * pageNew} - </p> - <select - id='limitSelect' - value={limitNew} - onChange={(e) => { - setLimitNew(Number(e.target.value)); - setPageNew(1); - }} - className='border p-2 text-xs' - > - <option value={10}>10</option> - <option value={15}>15</option> - <option value={20}>20</option> - </select> + )} </div> </div> @@ -634,7 +559,9 @@ const Transactions = ({ context = '' }) => { > <div className='flex flex-row justify-between items-start'> <Link href={`${router.pathname}/${saleOrder.id}`}> - <h2 className='text-danger-500'>{saleOrder.name}</h2> + <h2 className='text-danger-500 text-base'> + {saleOrder.name} + </h2> <span className='font-medium text-black opacity-75'> {formatDate(saleOrder.dateOrder.split(' ')[0]) || '-'} </span> @@ -662,8 +589,8 @@ const Transactions = ({ context = '' }) => { </div> <div className='flex w-4/5 flex-col gap-2 justify-start'> <p className='flex flex-row gap-2'> - <span className=' text-black'>Nomor PO:</span> - <span className=' text-red-500 font-semibold'> + <span className=' text-black text-sm'>Nomor PO:</span> + <span className=' text-red-500 font-semibold text-sm'> {saleOrder.purchaseOrderName || '-'} </span> </p> @@ -681,29 +608,30 @@ const Transactions = ({ context = '' }) => { <div className='flex flex-row gap-1 justify-start items-center'> {saleOrder.products .slice(1, 4) - .map((product, index) => ( + .map((product, idx) => ( <Image - key={index} // Tambahkan key untuk setiap elemen dalam map() + key={idx} src={product?.parent?.image} alt={product?.name} className='object-contain object-center border border-gray_r-6 h-8 w-8 rounded-md' /> ))} {saleOrder.products.length > 4 ? ( - <Link - href={`${router.pathname}/${saleOrder?.id}`} - className='text-red-500 text-nowrap' - > - +{saleOrder.products.length - 4} lihat semua produk - </Link> - ) : ( - <Link - href={`${router.pathname}/${saleOrder?.id}`} - className='text-red-500 text-nowrap' - > - Lihat semua produk - </Link> - )} + <Link + href={`${router.pathname}/${saleOrder?.id}`} + className='text-red-500 text-nowrap' + > + +{saleOrder.products.length - 4} lihat semua + produk + </Link> + ) : ( + <Link + href={`${router.pathname}/${saleOrder?.id}`} + className='text-red-500 text-nowrap' + > + Lihat semua produk + </Link> + )} </div> )} </div> @@ -716,58 +644,50 @@ const Transactions = ({ context = '' }) => { </div> </div> <div className='col-span-2 h-[1px] w-full bg-gray-300'></div> - <div className='flex flex-row gap-3 justify-between items-center text-sm'> - <div className='flex flex-col text-black text-xs'> + + <div className='flex flex-col gap-3 text-sm'> + <div className='flex flex-col text-black'> <p className='font-extralight'>Total Harga</p> - <p className='font-semibold'> + <p className='font-semibold text-lg'> {currencyFormat(saleOrder.amountTotal)} </p> </div> - <div> - <button - type='button' - onClick={() => handleBuyBack(saleOrder.products)} - className='flex-1 py-2 btn-solid-red text-nowrap' - > - Beli Lagi - </button> - </div> - </div> - {/* <div className='grid grid-cols-2 mt-3'> - <div> - <span className='text-caption-2 text-gray_r-11'> - No. Purchase Order - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.purchaseOrderName || '-'} - </p> - </div> - <div className='text-right'> - <span className='text-caption-2 text-gray_r-11'> - Total Invoice - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.invoiceCount} Invoice - </p> - </div> - </div> */} - {/* <div className='grid grid-cols-2 mt-3'> - <div> - <span className='text-caption-2 text-gray_r-11'>Sales</span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.sales} - </p> - </div> - <div className='text-right'> - <span className='text-caption-2 text-gray_r-11'> - Total Harga - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {currencyFormat(saleOrder.amountTotal)} - </p> + <div className='flex flex-col gap-2 w-full'> + {/* Beli Lagi hanya muncul jika status bukan unpaid */} + {!isUnpaid(saleOrder.status) && ( + <button + type='button' + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleBuyBack(saleOrder.products); + }} + className='w-full py-2 btn-solid-red text-center rounded-md' + > + Beli Lagi + </button> + )} + + {/* Bayar Sekarang hanya kalau eligible */} + {saleOrder?.eligibleContinue && ( + <button + type='button' + disabled={contLoadingId === saleOrder.id} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContinuePayment(saleOrder); + }} + className='w-full py-2 text-center rounded-md border border-red-300 text-red-500 bg-white disabled:opacity-60' + > + {contLoadingId === saleOrder.id + ? 'Memproses…' + : 'Bayar Sekarang'} + </button> + )} </div> - </div> */} + </div> </Link> </div> ))} @@ -775,7 +695,6 @@ const Transactions = ({ context = '' }) => { <Pagination pageCount={pageCount} currentPage={parseInt(pageNew)} - // url={router.pathname + pageQuery} url={`${router.pathname}?${toQuery(_.omit(query, ['page']))}`} className='mt-2 mb-2' /> @@ -785,6 +704,30 @@ const Transactions = ({ context = '' }) => { active={toOthers} close={() => setToOthers(null)} > + {transactions.data?.status === 'draft' && ( + <> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'draft'} + onClick={() => { + downloadQuotation(toOthers); + setToOthers(null); + }} + > + Download Quotation + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'waiting'} + onClick={() => { + setToCancel(toOthers); + setToOthers(null); + }} + > + Batalkan Transaksi + </button> + </> + )} <div className='flex flex-col gap-y-4 mt-2'> <button className='text-left disabled:opacity-60' @@ -796,26 +739,6 @@ const Transactions = ({ context = '' }) => { > Download PO </button> - <button - className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'draft'} - onClick={() => { - downloadQuotation(toOthers); - setToOthers(null); - }} - > - Download Quotation - </button> - <button - className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'waiting'} - onClick={() => { - setToCancel(toOthers); - setToOthers(null); - }} - > - Batalkan Transaksi - </button> </div> </BottomPopup> @@ -848,6 +771,7 @@ const Transactions = ({ context = '' }) => { </div> </MobileView> + {/* ===== DESKTOP ===== */} <DesktopView> <div className='container mx-auto flex py-10'> <div className='w-3/12 pr-4'> @@ -900,51 +824,36 @@ const Transactions = ({ context = '' }) => { )} </div> </div> - <div className=''> - <div - class='flex items-center p-4 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' - role='alert' - > - <svg - class='flex-shrink-0 inline w-5 h-5 mr-2' - aria-hidden='true' - fill='currentColor' - viewBox='0 0 20 20' - > - <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> - </svg> - <span class='sr-only'>Info</span> - <div className='text-justify flex flex-col gap-1'> - <p className='font-bold text-black'>Info Transaksi</p> - <span className='text-black'> - Gunakan filter status untuk mempermudah pencarian transaksi anda di Daftar Transaksi - </span> - </div> - </div> - </div> + <div className='flex flex-col gap-y-2 border rounded-lg mb-2 w-full'> <div className='p-2'> <div className='flex items-center space-x-3'> <span className='text-base font-semibold text-gray-600'> Status </span> - <div className="relative w-full overflow-hidden"> - {/* Container flex: tombol prev - swiper - tombol next */} - <div className="flex items-center space-x-2"> - - {/* Prev */} - <button className="custom-prev w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-1"> - <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + <div className='relative w-full overflow-hidden'> + <div className='flex items-center space-x-2'> + <button className='custom-prev w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-1'> + <svg + className='w-4 h-4 text-gray-500' + fill='none' + stroke='currentColor' + viewBox='0 0 24 24' + > + <path + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={2} + d='M15 19l-7-7 7-7' + /> </svg> </button> - {/* Swiper container scrollable */} - <div className="w-full overflow-hidden"> + <div className='w-full overflow-hidden'> <Swiper spaceBetween={10} - slidesPerView="auto" - className="status-swiper" + slidesPerView='auto' + className='status-swiper' modules={[Navigation]} navigation={{ nextEl: '.custom-next', @@ -952,12 +861,13 @@ const Transactions = ({ context = '' }) => { }} > {statuses.map((status) => ( - <SwiperSlide key={status.id} className="!w-auto"> + <SwiperSlide key={status.id} className='!w-auto'> <button className={`px-4 py-1 text-sm font-medium border rounded-lg transition whitespace-nowrap - ${statusNew === status.id - ? 'border-red-500 text-red-500 bg-white' - : 'border-gray-300 text-gray-400 bg-gray-100 hover:bg-gray-200' + ${ + statusNew === status.id + ? 'border-red-500 text-red-500 bg-white' + : 'border-gray-300 text-gray-400 bg-gray-100 hover:bg-gray-200' }`} onClick={() => handleStatusChange(status.id)} > @@ -968,16 +878,26 @@ const Transactions = ({ context = '' }) => { </Swiper> </div> - {/* Next */} - <button className="custom-next w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-10"> - <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + <button className='custom-next w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-10'> + <svg + className='w-4 h-4 text-gray-500' + fill='none' + stroke='currentColor' + viewBox='0 0 24 24' + > + <path + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={2} + d='M9 5l7 7-7 7' + /> </svg> </button> </div> </div> </div> </div> + <div className='flex flex-row items-center justify-between mb-2 p-2'> <div className='flex flex-col gap-2 pb-2'> {listSites?.length > 0 ? ( @@ -1013,6 +933,7 @@ const Transactions = ({ context = '' }) => { </button> </form> </div> + <div className='flex flex-row gap-4 items-center justify-center'> <p> Menampilkan {startItem}- @@ -1038,87 +959,66 @@ const Transactions = ({ context = '' }) => { <option value={10}>10</option> <option value={15}>15</option> <option value={20}>20</option> - <option value={transactions?.data?.saleOrderTotal}>Semua</option> + <option value={transactions?.data?.saleOrderTotal}> + Semua + </option> </select> - <div ref={calendarRef} className="relative inline-block"> + + <div ref={calendarRef} className='relative inline-block'> <button type='button' className='p-2 w-auto h-auto cursor-pointer border hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' onClick={() => setIsOpenCalender((prev) => !prev)} > <span className='text-nowrap px-1 truncate flex items-center gap-1'> - {state[0]?.startDate ? ( - `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` - ) : ( - <Calendar size={16} className="text-gray-500" /> - )} + {state[0]?.startDate ? ( + `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` + ) : ( + <Calendar size={16} className='text-gray-500' /> + )} </span> </button> {isOpenCalender && ( - <div className='absolute right-10 mt-2 bg-white p-4 rounded shadow-lg z-50'> - {/* Tombol silang di sudut kanan atas */} - <button - onClick={() => setIsOpenCalender(false)} - className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' - > - × - </button> - <DateRangePicker - onChange={(item) => setState([item.selection])} - showSelectionPreview={false} - maxDate={new Date()} - moveRangeOnFirstSelection={false} - months={1} - ranges={state} - className='w-full' - /> - <style>{` - /* Atur container agar menjadi column */ - .rdrCalendarWrapper { - display: flex; - flex-direction: column; - } - .rdrDateRangePickerWrapper { - display: flex; - flex-direction: column; - } - - /* Pindahkan rdrStaticRanges ke atas */ - .rdrDefinedRangesWrapper { - order: -1; - width: fit-content; - } - .rdrStaticRanges { - flex-direction: row; - margin-right: 2px; - } - - /* Sembunyikan bagian input manual */ - .rdrInputRanges { - display: none !important; - } - - .rdrStaticRangeLabel { - padding: 10px 10px; - } - .rdrMonth { - width: -moz-available; - } - `}</style> - <div className='flex flex-row justify-end gap-3 mt-2'> + <div className='absolute right-10 mt-2 bg-white p-4 rounded shadow-lg z-50'> <button - className='px-4 py-1 bg-red-500 text-white rounded' - onClick={handleReset} + onClick={() => setIsOpenCalender(false)} + className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' > - Reset + × </button> + <DateRangePicker + onChange={(item) => setState([item.selection])} + showSelectionPreview={false} + maxDate={new Date()} + moveRangeOnFirstSelection={false} + months={1} + ranges={state} + className='w-full' + /> + <style>{` + .rdrCalendarWrapper{display:flex;flex-direction:column;} + .rdrDateRangePickerWrapper{display:flex;flex-direction:column;} + .rdrDefinedRangesWrapper{order:-1;width:fit-content;} + .rdrStaticRanges{flex-direction:row;margin-right:2px;} + .rdrInputRanges{display:none !important;} + .rdrStaticRangeLabel{padding:10px 10px;} + .rdrMonth{width:-moz-available;} + `}</style> + <div className='flex flex-row justify-end gap-3 mt-2'> + <button + className='px-4 py-1 bg-red-500 text-white rounded' + onClick={handleReset} + > + Reset + </button> + </div> </div> - </div> )} </div> </div> </div> </div> + <div className='flex justify-center items-center'> {!transactions.isLoading && transactions?.data?.saleOrders?.length == 0 && ( @@ -1156,11 +1056,9 @@ const Transactions = ({ context = '' }) => { <p className='text-red-500'>{saleOrder.name}</p> <p className='text-black'> Salesperson:{' '} - { - <span className='font-semibold'> - {saleOrder.sales} - </span> - } + <span className='font-semibold'> + {saleOrder.sales} + </span> </p> </div> <div className='text-black'> @@ -1172,7 +1070,9 @@ const Transactions = ({ context = '' }) => { </span> </div> </div> + <hr className='mt-3 mb-3 border border-gray-100' /> + <div className='flex flex-row gap-2 justify-between items-center '> <div className='flex justify-start w-3/4 flex-col gap-2'> <div className='flex gap-2'> @@ -1207,9 +1107,9 @@ const Transactions = ({ context = '' }) => { <div className='flex flex-row gap-1 justify-start items-center'> {saleOrder.products .slice(1, 4) - .map((product, index) => ( + .map((product, idx) => ( <Image - key={index} // Tambahkan key untuk setiap elemen dalam map() + key={idx} src={product?.parent?.image} alt={product?.name} className='object-contain object-center border border-gray_r-6 h-16 w-16 rounded-md' @@ -1220,7 +1120,8 @@ const Transactions = ({ context = '' }) => { href={`${router.pathname}/${saleOrder?.id}`} className='text-red-500 text-nowrap' > - +{saleOrder.products.length - 4} lihat semua produk + +{saleOrder.products.length - 4}{' '} + lihat semua produk </Link> ) : ( <Link @@ -1244,25 +1145,48 @@ const Transactions = ({ context = '' }) => { </p> </div> </div> + <div className='w-[1px] h-24 bg-gray-300'></div> - <div className='w-1/4 flex flex-row gap-3 justify-center items-center'> + + <div className='w-1/4 flex flex-col gap-2 items-center justify-center text-center pl-5'> <div className='flex flex-col text-black'> - <p>Total Harga</p> - <p className='font-bold'> + <p className='text-sm'>Total Harga</p> + <p className='font-bold text-lg'> {currencyFormat(saleOrder.amountTotal)} </p> </div> - <div> + + {!isUnpaid(saleOrder.status) && ( <button type='button' - onClick={() => - handleBuyBack(saleOrder.products) - } - className='flex-1 py-2 btn-solid-red text-nowrap' + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleBuyBack(saleOrder.products); + }} + className='w-full py-2 btn-solid-red text-nowrap rounded-md' > Beli Lagi </button> - </div> + )} + + {/* Bayar Sekarang: hanya kalau eligible */} + {saleOrder?.eligibleContinue && ( + <button + type='button' + disabled={contLoadingId === saleOrder.id} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContinuePayment(saleOrder); + }} + className='w-full py-2 text-nowrap border border-red-500 text-red-500 rounded-md disabled:opacity-60' + > + {contLoadingId === saleOrder.id + ? 'Memproses…' + : 'Bayar Sekarang'} + </button> + )} </div> </div> </Link> @@ -1271,70 +1195,10 @@ const Transactions = ({ context = '' }) => { </div> )} </div> - {/* <table className='table-data'> - <thead> - <tr> - <th>No. Transaksi</th> - <th>No. PO</th> - <th>Tanggal</th> - <th>Created By</th> - {auth?.feature?.soApproval && <th>Site</th>} - <th className='!text-left'>Salesperson</th> - <th className='!text-left'>Total</th> - <th>Status</th> - </tr> - </thead> - <tbody> - {transactions.isLoading && ( - <tr> - <td colSpan={7}> - <div className='flex justify-center my-2'> - <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> - </div> - </td> - </tr> - )} - {!transactions.isLoading && - (!transactions?.data?.saleOrders || - transactions?.data?.saleOrders?.length == 0) && ( - <tr> - <td colSpan={7}>Tidak ada transaksi</td> - </tr> - )} - {transactions.data?.saleOrders?.map((saleOrder) => ( - <tr key={saleOrder.id}> - <td> - <Link - className='whitespace-nowrap' - href={`${router.pathname}/${saleOrder.id}`} - > - {saleOrder.name} - </Link> - </td> - <td>{saleOrder.purchaseOrderName || '-'}</td> - <td>{saleOrder.dateOrder || '-'}</td> - <td>{saleOrder.address.customer?.name || '-'}</td> - {auth?.feature?.soApproval && ( - <td>{saleOrder.sitePartner || '-'}</td> - )} - <td className='!text-left'>{saleOrder.sales}</td> - <td className='!text-left'> - {currencyFormat(saleOrder.amountTotal)} - </td> - <td> - <div className='flex justify-center'> - <TransactionStatusBadge status={saleOrder.status} /> - </div> - </td> - </tr> - ))} - </tbody> - </table> */} <Pagination pageCount={pageCount} currentPage={parseInt(pageNew)} - // url={router.pathname + (pageQuery ? `?${pageQuery}` : '')} url={`${router.pathname}?${toQuery(_.omit(query, ['page']))}`} className='mt-2 mb-2' /> diff --git a/src/lib/treckingAwb/component/Manifest.jsx b/src/lib/treckingAwb/component/Manifest.jsx index 6eb0b0ac..d2896567 100644 --- a/src/lib/treckingAwb/component/Manifest.jsx +++ b/src/lib/treckingAwb/component/Manifest.jsx @@ -9,17 +9,24 @@ import { list } from 'postcss'; import InformationSection from './InformationSection'; import Link from 'next/link'; -function capitalizeFirstLetter(str) { - return str.charAt(0).toUpperCase() + str.slice(1); -} +// function capitalizeFirstLetter(str) { +// return str.charAt(0).toUpperCase() + str.slice(1); +// } function capitalizeWords(str) { + if (!str || typeof str !== 'string') { + return ''; +} return str .split(' ') .map((word) => capitalizeFirstLetter(word)) .join(' '); } +function capitalizeFirstLetter(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); +} + function mappingLiveTracking(kurir, resi){ let url = null switch (kurir){ |
