diff options
Diffstat (limited to 'src/lib')
26 files changed, 2403 insertions, 748 deletions
diff --git a/src/lib/address/api/editPartnerApi.js b/src/lib/address/api/editPartnerApi.js new file mode 100644 index 00000000..866ee9d2 --- /dev/null +++ b/src/lib/address/api/editPartnerApi.js @@ -0,0 +1,12 @@ +import odooApi from '@/core/api/odooApi' + +const editPartnerApi = async ({ id, data }) => { + const dataPartner = await odooApi('POST', `/api/v1/partner/${id}`, data, { + headers: { + 'Content-Type': 'application/json', + } + }); + return dataPartner; +} + +export default editPartnerApi;
\ No newline at end of file diff --git a/src/lib/address/components/Addresses.jsx b/src/lib/address/components/Addresses.jsx index 9ca617ae..1007b9f8 100644 --- a/src/lib/address/components/Addresses.jsx +++ b/src/lib/address/components/Addresses.jsx @@ -9,6 +9,7 @@ import MobileView from '@/core/components/views/MobileView'; import DesktopView from '@/core/components/views/DesktopView'; import Menu from '@/lib/auth/components/Menu'; import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; +import { MapPinIcon } from 'lucide-react'; const Addresses = () => { const router = useRouter(); @@ -17,7 +18,7 @@ const Addresses = () => { const selectedAddress = getItemAddress(select || ''); const [changeConfirmation, setChangeConfirmation] = useState(false); const [selectedForChange, setSelectedForChange] = useState(null); // State baru untuk simpan alamat yang akan diubah - + const changeSelectedAddress = (id) => { if (!select) return; updateItemAddress(select, id); @@ -177,6 +178,20 @@ const AddressCard = ({ <p className='mt-2 text-gray_r-11'>{address.mobile}</p> )} <p className='mt-1 leading-6 text-gray_r-11'>{address.street}</p> + + <div className='flex items-center mt-4'> + {address.addressMap ? ( + <> + <MapPinIcon class='h-7 w-8 text-yellow-600 mr-3 font-semibold' /> + <p className='text-yellow-600 font-semibold'>Sudah PinPoint</p> + </> + ) : ( + <> + <MapPinIcon class='h-7 w-8 text-red-600 mr-2 font-semibold' /> + <p className='text-red-600 font-semibold text-sm'>Belum PinPoint</p> + </> + )} + </div> </div> <button onClick={() => { diff --git a/src/lib/address/components/CreateAddress.jsx b/src/lib/address/components/CreateAddress.jsx index 97db7ed8..963a19aa 100644 --- a/src/lib/address/components/CreateAddress.jsx +++ b/src/lib/address/components/CreateAddress.jsx @@ -1,18 +1,24 @@ import HookFormSelect from '@/core/components/elements/Select/HookFormSelect'; import useAuth from '@/core/hooks/useAuth'; +import Menu from '@/lib/auth/components/Menu'; +import { yupResolver } from '@hookform/resolvers/yup'; import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; import * as Yup from 'yup'; import cityApi from '../api/cityApi'; +import createAddressApi from '../api/createAddressApi'; import districtApi from '../api/districtApi'; +import stateApi from '../api/stateApi'; import subDistrictApi from '../api/subDistrictApi'; -import { useEffect, useState } from 'react'; -import createAddressApi from '../api/createAddressApi'; -import { toast } from 'react-hot-toast'; -import { yupResolver } from '@hookform/resolvers/yup'; -import Menu from '@/lib/auth/components/Menu'; import useAddresses from '../hooks/useAddresses'; -import stateApi from '../api/stateApi'; + +import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; +import { Button } from '@chakra-ui/react'; +import { MapPinIcon } from 'lucide-react'; +import PinPointMap from '../../maps/components/PinPointMap'; +import { useMaps } from '../../maps/stores/useMaps'; const CreateAddress = () => { const auth = useAuth(); @@ -34,6 +40,27 @@ const CreateAddress = () => { const [districts, setDistricts] = useState([]); const [subDistricts, setSubDistricts] = useState([]); const [filteredTypes, setFilteredTypes] = useState(types); // State to manage filtered types + const { + addressMaps, + selectedPosition, + detailAddress, + setAddressMaps, + 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]); useEffect(() => { const loadState = async () => { @@ -45,6 +72,7 @@ const CreateAddress = () => { setState(dataState); }; loadState(); + setAddressMaps(''); }, []); const watchState = watch('state'); @@ -64,6 +92,20 @@ 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) { let hasContactAddress = false; @@ -97,6 +139,21 @@ const CreateAddress = () => { } }, [watchCity, setValue]); + useEffect(() => { + if (detailAddress && Object.keys(detailAddress).length > 0) { + const selectedDistrict = districts.find( + (district) => + detailAddress.subDistrict + .toLowerCase() + .includes(district.label.toLowerCase()) || + district.label + .toLowerCase() + .includes(detailAddress.subDistrict.toLowerCase()) + ); + setValue('district', selectedDistrict?.value); + } + }, [districts, detailAddress, setValue]); + const watchDistrict = watch('district'); useEffect(() => { setValue('subDistrict', ''); @@ -115,6 +172,22 @@ const CreateAddress = () => { } }, [watchDistrict, setValue]); + useEffect(() => { + if (detailAddress && Object.keys(detailAddress).length > 0) { + const selectedSubDistrict = subDistricts.find( + (district) => + detailAddress.village + .toLowerCase() + .includes(district.label.toLowerCase()) || + district.label + .toLowerCase() + .includes(detailAddress.village.toLowerCase()) + ); + + setValue('subDistrict', selectedSubDistrict?.value); + } + }, [subDistricts, detailAddress, setValue]); + const onSubmitHandler = async (values) => { const data = { ...values, @@ -123,8 +196,10 @@ const CreateAddress = () => { district_id: values.district, sub_district_id: values.subDistrict, parent_id: auth.partnerId, + latitude: selectedPosition?.lat, + longtitude: selectedPosition?.lng, + address_map: addressMaps, }; - const address = await createAddressApi({ data }); if (address?.id) { toast.success('Berhasil menambahkan alamat'); @@ -133,171 +208,201 @@ const CreateAddress = () => { }; return ( - <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 /> - </div> - <div className='w-full md:w-9/12 p-4 bg-white border border-gray_r-6 rounded'> - <form onSubmit={handleSubmit(onSubmitHandler)}> - <div className='grid grid-cols-1 md:grid-cols-2 gap-4'> - <div> - <label className='form-label mb-2'>Label Alamat</label> - <Controller - name='type' - control={control} - render={(props) => ( - <HookFormSelect - {...props} - isSearchable={false} - options={filteredTypes} - /> - )} - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.type?.message} - </div> + <> + <BottomPopup + className=' !h-[75%]' + title='Pin Koordinat Address' + active={pinedMaps} + close={() => setPinedMaps(false)} + > + <div className='flex mt-4'> + <PinPointMap /> + </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 /> + </div> + <div className='w-full md:w-9/12 p-4 bg-white border border-gray_r-6 rounded'> + <form onSubmit={handleSubmit(onSubmitHandler)}> + <div className='mb-4 items-start'> + <label className='form-label mb-2'>Koordinat Alamat</label> + {addressMaps ? ( + <div className='flex items-center'> + <button type='button' className="flex items-center justify-center me-3 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition"> + <MapPinIcon class='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 bg-red-500 text-white rounded-full hover:bg-red-600 transition"> + <MapPinIcon className="h-6 w-6" /> + </button> + Pin Koordinat Alamat + </Button> + )} </div> + <div className='grid grid-cols-1 md:grid-cols-2 gap-4'> + <div> + <label className='form-label mb-2'>Label Alamat</label> + <Controller + name='type' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + isSearchable={false} + options={filteredTypes} + /> + )} + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.type?.message} + </div> + </div> - <div> - <label className='form-label mb-2'>Nama</label> - <input - {...register('name')} - placeholder='John Doe' - type='text' - className='form-input' - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.name?.message} + <div> + <label className='form-label mb-2'>Nama</label> + <input + {...register('name')} + placeholder='John Doe' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.name?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Email</label> - <input - {...register('email')} - placeholder='contoh@email.com' - type='email' - className='form-input' - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.email?.message} + <div> + <label className='form-label mb-2'>Email</label> + <input + {...register('email')} + placeholder='contoh@email.com' + type='email' + className='form-input' + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.email?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Mobile</label> - <input - {...register('mobile')} - placeholder='08xxxxxxxx' - type='tel' - className='form-input' - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.mobile?.message} + <div> + <label className='form-label mb-2'>Mobile</label> + <input + {...register('mobile')} + placeholder='08xxxxxxxx' + type='tel' + className='form-input' + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.mobile?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Alamat</label> - <input - {...register('street')} - placeholder='Jl. Bandengan Utara 85A' - type='text' - className='form-input' - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.street?.message} + <div> + <label className='form-label mb-2'>Alamat</label> + <input + {...register('street')} + placeholder='Jl. Bandengan Utara 85A' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.street?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Kode Pos</label> - <input - {...register('zip')} - placeholder='10100' - type='number' - className='form-input' - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.zip?.message} + <div> + <label className='form-label mb-2'>Kode Pos</label> + <input + {...register('zip')} + placeholder='10100' + type='number' + className='form-input' + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.zip?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Provinsi</label> - <Controller - name='state' - control={control} - render={(props) => ( - <HookFormSelect {...props} options={states} /> - )} - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.state?.message} + <div> + <label className='form-label mb-2'>Provinsi</label> + <Controller + name='state' + control={control} + render={(props) => ( + <HookFormSelect {...props} options={states} /> + )} + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.state?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Kota</label> - <Controller - name='city' - control={control} - render={(props) => ( - <HookFormSelect - {...props} - options={cities} - disabled={!watchState} - /> - )} - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.city?.message} + <div> + <label className='form-label mb-2'>Kota</label> + <Controller + name='city' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={cities} + disabled={!watchState} + /> + )} + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.city?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Kecamatan</label> - <Controller - name='district' - control={control} - render={(props) => ( - <HookFormSelect - {...props} - options={districts} - disabled={!watchCity} - /> - )} - /> - <div className='text-caption-2 text-danger-500 mt-1'> - {errors.district?.message} + <div> + <label className='form-label mb-2'>Kecamatan</label> + <Controller + name='district' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={districts} + disabled={!watchCity} + /> + )} + /> + <div className='text-caption-2 text-danger-500 mt-1'> + {errors.district?.message} + </div> </div> - </div> - <div> - <label className='form-label mb-2'>Kelurahan</label> - <Controller - name='subDistrict' - control={control} - render={(props) => ( - <HookFormSelect - {...props} - options={subDistricts} - disabled={!watchDistrict} - /> - )} - /> + <div> + <label className='form-label mb-2'>Kelurahan</label> + <Controller + name='subDistrict' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={subDistricts} + disabled={!watchDistrict} + /> + )} + /> + </div> </div> - </div> - <button - type='submit' - className='btn-yellow w-full md:w-fit mt-6 ml-0 md:ml-auto' - > - Simpan - </button> - </form> + <button + type='submit' + className='btn-yellow w-full md:w-fit mt-6 ml-0 md:ml-auto' + > + Simpan + </button> + </form> + </div> </div> - </div> + </> ); }; diff --git a/src/lib/address/components/EditAddress.jsx b/src/lib/address/components/EditAddress.jsx index ba6bd25b..deaa8a3e 100644 --- a/src/lib/address/components/EditAddress.jsx +++ b/src/lib/address/components/EditAddress.jsx @@ -8,12 +8,20 @@ import districtApi from '../api/districtApi'; import subDistrictApi from '../api/subDistrictApi'; import addressApi from '@/lib/address/api/addressApi'; import editAddressApi from '../api/editAddressApi'; +import editPartnerApi from '../api/editPartnerApi'; import HookFormSelect from '@/core/components/elements/Select/HookFormSelect'; import { toast } from 'react-hot-toast'; import Menu from '@/lib/auth/components/Menu'; import useAuth from '@/core/hooks/useAuth'; import odooApi from '@/core/api/odooApi'; import stateApi from '../api/stateApi'; +import { MapPinIcon } from 'lucide-react'; +import { Button } from '@chakra-ui/react'; +import { useMaps } from '../../maps/stores/useMaps'; + +import PinPointMap from '../../maps/components/PinPointMap'; +import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; +import { data } from 'autoprefixer'; const EditAddress = ({ id, defaultValues }) => { const auth = useAuth(); @@ -35,7 +43,36 @@ const EditAddress = ({ id, defaultValues }) => { const [cities, setCities] = useState([]); const [districts, setDistricts] = useState([]); const [subDistricts, setSubDistricts] = useState([]); + const [tempAddress, setTempAddress] = useState(getValues('addressMap')); + const { addressMaps, + selectedPosition, + detailAddress, + pinedMaps, + setPinedMaps } = useMaps(); + + useEffect(() => { + if (addressMaps) { + setTempAddress(addressMaps); + setValue('addressMap', addressMaps); + setValue('longtitude', selectedPosition.lng); + setValue('latitude', selectedPosition.lat); + } + }, [addressMaps, selectedPosition, setValue]); + + useEffect(() => { + if (Object.keys(detailAddress).length > 0) { + 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]); + useEffect(() => { const loadProfile = async () => { const dataProfile = await addressApi({ id: auth.partnerId }); @@ -52,7 +89,7 @@ const EditAddress = ({ id, defaultValues }) => { }; if (auth) loadProfile(); }, [auth?.parentId]); - + useEffect(() => { const loadStates = async () => { let dataStates = await stateApi({ tempo: false }); @@ -64,11 +101,11 @@ const EditAddress = ({ id, defaultValues }) => { }; loadStates(); }, []); - + const watchState = watch('state'); useEffect(() => { + setValue('city', ''); if (watchState) { - setValue('city', ''); const loadCities = async () => { let dataCities = await cityApi({ stateId: watchState }); dataCities = dataCities.map((city) => ({ @@ -85,16 +122,30 @@ const EditAddress = ({ id, defaultValues }) => { loadCities(); } }, [watchState, setValue, getValues]); - - 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 (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]); + + 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, })); setDistricts(dataDistricts); let oldDistrict = getValues('oldDistrict'); @@ -106,7 +157,24 @@ const EditAddress = ({ id, defaultValues }) => { loadDistricts(); } }, [watchCity, setValue, getValues]); - + + useEffect(() => { + if (Object.keys(detailAddress).length > 0) { + const selectedDistrict = districts.find( + (district) => + detailAddress.subDistrict + .toLowerCase() + .includes(district.label.toLowerCase()) || + district.label + .toLowerCase() + .includes(detailAddress.subDistrict.toLowerCase()) + ); + setValue('district', selectedDistrict?.value); + } + }, [districts, detailAddress, setValue]); + + + const watchDistrict = watch('district'); useEffect(() => { if (watchDistrict) { @@ -130,48 +198,126 @@ const EditAddress = ({ id, defaultValues }) => { loadSubDistricts(); } }, [watchDistrict, setValue, getValues]); + + + useEffect(() => { + if (Object.keys(detailAddress).length > 0) { + const selectedSubDistrict = subDistricts.find( + (district) => + detailAddress.village + .toLowerCase() + .includes(district.label.toLowerCase()) || + district.label + .toLowerCase() + .includes(detailAddress.village.toLowerCase()) + ); + + setValue('subDistrict', selectedSubDistrict?.value); + } + }, [subDistricts, detailAddress, setValue]); + + useEffect(() => { + if (id) { + setValue('id', id); + } + }, [id, setValue]); + const onSubmitHandler = async (values) => { const data = { ...values, phone: values.mobile, - state_id: values.state, - city_id: values.city, - district_id: values.district, - sub_district_id: values.subDistrict, + state_id: parseInt(values.state, 10), + 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 (!auth.company) { data.alamat_lengkap_text = values.street; } - const address = await editAddressApi({ id, data }); - let dataAlamat; - let isUpdated = true; - if (auth.company) { - if (auth?.partnerId == id) { - dataAlamat = { - id_user: auth.partnerId, - alamat_lengkap_text: values.alamat_wajib_pajak, + 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) { + const dataProfile = await addressApi({ id: auth.partnerId }); + const dataAlamat = { + id_user: auth.id, + company_type_id: dataProfile.companyTypeId, + industry_id: dataProfile.industryId, + tax_name: values.taxName, + npwp: values.npwp, + alamat_lengkap_text: values.alamat_wajib_pajak || values.street, street: values.street, + email: values.email, + mobile: values.mobile, }; - isUpdated = await odooApi( - 'PUT', - `/api/v1/partner/${auth.parentId}`, - dataAlamat - ); + + const isUpdated = await editPartnerApi({ + id: auth.partnerId, + data: dataAlamat, + }); + + console.log('Response isUpdated:', isUpdated); } + + // Validasi kondisi sukses + const isSuccess = !!address?.id; + + if (isSuccess) { + toast.success('Berhasil mengubah alamat'); + router.back(); + } else { + const errorMsg = + address?.message || + isUpdated?.message || + 'Gagal memperbarui alamat, silakan coba lagi.'; + toast.error(errorMsg); + } + } catch (error) { + console.error('Catch error:', error); + 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 && auth?.partnerId == id ? isUpdated?.id : true)) { - toast.success('Berhasil mengubah alamat'); - router.back(); - } else { - toast.error('Terjadi kesalahan internal'); - router.back(); - } + // if (address?.id && auth.company ? isUpdated?.id : true) { + // toast.success('Berhasil mengubah alamat'); + // router.back(); + // } else { + // toast.error('Terjadi kesalahan internal'); + // router.back(); + // } }; return ( <> + <BottomPopup + className=' !h-[75%]' + title='Pin Maps Address' + active={pinedMaps} + close={() => setPinedMaps(false)} + > + <div className='flex mt-4'> + <PinPointMap + initialLatitude={selectedPosition?.lat} + initialLongitude={selectedPosition?.lng} + initialAddress={tempAddress} + /> + + </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 /> @@ -184,6 +330,24 @@ const EditAddress = ({ id, defaultValues }) => { {auth?.partnerId == id && <div className='badge-green'>Utama</div>} </div> <form onSubmit={handleSubmit(onSubmitHandler)}> + <div className='mb-4 items-start'> + <label className='form-label mb-2'>Koordinat Alamat</label> + {tempAddress ? ( + <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> + </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> + Pin Koordinat Alamat + </Button> + )} + </div> <div className='grid grid-cols-1 md:grid-cols-2 gap-4'> <div> <label className='form-label mb-2'>Label Alamat</label> diff --git a/src/lib/checkout/api/ExpedisiList.js b/src/lib/checkout/api/ExpedisiList.js index ca22bec1..67ef93e2 100644 --- a/src/lib/checkout/api/ExpedisiList.js +++ b/src/lib/checkout/api/ExpedisiList.js @@ -1,8 +1,7 @@ -import odooApi from '@/core/api/odooApi' - +import odooApi from '@/core/api/odooApi'; const ExpedisiList = async () => { - const dataExpedisi = await odooApi('GET', '/api/v1/courier') - return dataExpedisi -} + const dataExpedisi = await odooApi('GET', '/api/v1/courier'); + return dataExpedisi; +}; -export default ExpedisiList +export default ExpedisiList; diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js index fd982fff..c30d9631 100644 --- a/src/lib/checkout/api/checkoutApi.js +++ b/src/lib/checkout/api/checkoutApi.js @@ -18,3 +18,12 @@ export const getProductsCheckout = async (query) => { const result = await odooApi('GET', url); return result; }; + +export const getProductsSla = async ({data}) => { + const dataSLA = await odooApi( + 'GET', + `/api/v1/product/variants/sla`, + data + ) + return dataSLA +} diff --git a/src/lib/checkout/api/getRatesCourier.js b/src/lib/checkout/api/getRatesCourier.js new file mode 100644 index 00000000..8db02d50 --- /dev/null +++ b/src/lib/checkout/api/getRatesCourier.js @@ -0,0 +1,22 @@ +import axios from "axios"; +import biteShipAPI from "../../../core/api/biteShip"; + +const GetRatesCourierBiteship = async ({ destination, items }) => { + const couriers = process.env.NEXT_PUBLIC_BITESHIP_CODE_COURIERS; + let body = { + ...destination, + couriers: 'gojek, grab, deliveree, lalamove, jne, tiki, ninja, lion, rara, sicepat, jnt, pos, idexpress, rpx, wahana, jdl, pos, anteraja, sap, paxel, borzo', + items: items, + }; + + const response = await axios(`${process.env.NEXT_PUBLIC_SELF_HOST}/api/biteship-service?method=POST&url=/v1/rates/couriers&body=` + JSON.stringify(body)); + + // const featch = await biteShipAPI('POST', '/v1/rates/couriers', body); + console.log('ini featch', response); + + + return response; +}; + + +export default GetRatesCourierBiteship
\ No newline at end of file diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx index 5256a328..d8ede118 100644 --- a/src/lib/checkout/components/Checkout.jsx +++ b/src/lib/checkout/components/Checkout.jsx @@ -28,9 +28,14 @@ import getFileBase64 from '@/core/utils/getFileBase64'; import { gtagPurchase } from '@/core/utils/googleTag'; import whatsappUrl from '@/core/utils/whatsappUrl'; import addressesApi from '@/lib/address/api/addressesApi'; +import { MapPinIcon } from 'lucide-react'; import CartItem from '~/modules/cart/components/Item.tsx'; import ExpedisiList from '../api/ExpedisiList'; -import { findVoucher, getVoucher, getVoucherNew } from '../api/getVoucher'; +import { getVoucher } from '../api/getVoucher'; +import { useAddress } from '../stores/useAdress'; +import SectionExpedition from './SectionExpedition'; +import { useCheckout } from '../stores/stateCheckout'; +import { formatShipmentRange, getToDate } from '../utils/functionCheckouit'; const SELF_PICKUP_ID = 32; @@ -50,9 +55,7 @@ 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; @@ -68,14 +71,21 @@ const Checkout = () => { source: query, voucher: activeVoucher, voucher_shipping: activeVoucherShipping, - }) + }), + { + keepPreviousData: true, // Menjaga data sebelumnya sampai data baru tersedia + } ); - const [selectedAddress, setSelectedAddress] = useState({ - shipping: null, - invoicing: null, - }); - const [addresses, setAddresses] = useState(null); + const { + selectedAddress, + setSelectedAddress, + addresses, + setAddresses, + setAddressMaps, + setCoordinate, + setPostalCode, + } = useAddress(); useEffect(() => { if (!auth) return; @@ -105,26 +115,32 @@ const Checkout = () => { return addresses[0]; }; + let ship = matchAddress('shipping'); + setSelectedAddress({ shipping: matchAddress('shipping'), invoicing: matchAddress('invoicing'), }); + setPostalCode(ship?.zip); + if (ship?.addressMap) { + setAddressMaps(ship?.addressMap); + setCoordinate({ + destination_latitude: ship?.latitude, + destination_longitude: ship?.longtitude, + }); + } }, [addresses]); - const [products, setProducts] = useState(null); const [totalWeight, setTotalWeight] = useState(0); const [priceCheck, setPriceCheck] = useState(false); - const [listExpedisi, setExpedisi] = useState([]); const [listserviceExpedisi, setListServiceExpedisi] = useState([]); const [selectedExpedisi, setSelectedExpedisi] = useState(0); const [selectedCarrierId, setselectedCarrierId] = useState(0); const [selectedCarrier, setselectedCarrier] = useState(0); - const [biayaKirim, setBiayaKirim] = useState(0); - const [checkWeigth, setCheckWeight] = useState(false); const [selectedServiceType, setSelectedServiceType] = useState(null); const [selectedExpedisiService, setselectedExpedisiService] = useState(null); - const [etd, setEtd] = useState(null); - const [etdFix, setEtdFix] = useState(null); + // const [etd, setEtd] = useState(null); + // const [etdFix, setEtdFix] = useState(null); const [bottomPopup, SetBottomPopup] = useState(null); const [bottomPopupTnC, SetBottomPopupTnC] = useState(null); const [itemTnC, setItemTnC] = useState(null); @@ -135,11 +151,29 @@ const Checkout = () => { const [findCodeVoucher, SetFindVoucher] = useState(null); const [selisihHargaCode, SetSelisihHargaCode] = useState(null); const [buttonTerapkan, SetButtonTerapkan] = useState(false); - const [checkoutValidation, setCheckoutValidation] = useState(false); const [loadingVoucher, setLoadingVoucher] = useState(true); const [loadingRajaOngkir, setLoadingRajaOngkir] = useState(false); const [grandTotal, setGrandTotal] = useState(0); - const [hasFlashSale, setHasFlashSale] = useState(false); + + const { + checkWeigth, + setCheckWeight, + hasFlashSale, + setHasFlashSale, + checkoutValidation, + setCheckoutValidation, + biayaKirim, + products, + setProducts, + etd, + unit, + selectedCourier, + selectedCourierId, + selectedService, + listExpedisi, + setExpedisi, + productSla + } = useCheckout(); const expedisiValidation = useRef(null); @@ -147,31 +181,16 @@ const Checkout = () => { if (!listVouchers) { try { setLoadingVoucher(true); - const productCategories = products - ?.reduce((categories, product) => { - if (product.categories && Array.isArray(product.categories)) { - product.categories.forEach((category) => { - if (category.id && !categories.includes(category.id)) { - categories.push(category.id); - } - }); - } - return categories; - }, []) - .join(','); - let dataVoucher = await getVoucher(auth?.id, { source: query, type: 'all,brand', - partner_id: auth?.partnerId, - voucher_category: productCategories, // Add the product categories + partner_id : auth?.partnerId, }); SetListVoucher(dataVoucher); let dataVoucherShipping = await getVoucher(auth?.id, { source: query, type: 'shipping', - voucher_category: productCategories, // Add the product categories }); SetListVoucherShipping(dataVoucherShipping); } finally { @@ -181,29 +200,17 @@ const Checkout = () => { }; const VoucherCode = async (code) => { - const productCategories = products - ?.reduce((categories, product) => { - if (product.categories && Array.isArray(product.categories)) { - product.categories.forEach((category) => { - if (category.id && !categories.includes(category.id)) { - categories.push(category.id); - } - }); - } - return categories; - }, []) - .join(','); - + // let dataVoucher = await findVoucher(code, auth.id, query); let dataVoucher = await getVoucher(auth?.id, { source: query, code: code, - voucher_category: productCategories, // Add the product categories }); if (dataVoucher.length <= 0) { SetFindVoucher(1); return; } + dataVoucher.forEach((addNewLine) => { if (addNewLine.applyType !== 'shipping') { // Mencari voucher dalam listVouchers @@ -296,6 +303,7 @@ const Checkout = () => { value: expedisi.id, label: expedisi.name, carrierId: expedisi.deliveryCarrierId, + logo: expedisi.image, })); setExpedisi(dataExpedisi); }; @@ -312,58 +320,6 @@ const Checkout = () => { }; }, []); - const hitungDiscountVoucher = (code, source) => { - let countDiscount = 0; - if (source === 'voucher') { - let dataVoucherIndex = listVouchers.findIndex( - (voucher) => voucher.code == code - ); - let dataActiveVoucher = listVouchers[dataVoucherIndex]; - - countDiscount = dataActiveVoucher.discountVoucher; - } else { - let dataVoucherIndex = listVoucherShippings.findIndex( - (voucher) => voucher.code == code - ); - let dataActiveVoucher = listVoucherShippings[dataVoucherIndex]; - - countDiscount = dataActiveVoucher.discountVoucher; - } - - /*if (dataActiveVoucher.discountType === 'percentage') { - countDiscount = cartCheckout?.subtotal * (dataActiveVoucher.discountAmount / 100) - if ( - dataActiveVoucher.maxDiscountAmount > 0 && - countDiscount > dataActiveVoucher.maxDiscountAmount - ) { - countDiscount = dataActiveVoucher.maxDiscountAmount - } - } else { - countDiscount = dataActiveVoucher.discountAmount - }*/ - - return countDiscount; - }; - - // useEffect(() => { - // if (!listVouchers) return; - // if (!activeVoucher) return; - - // console.log('voucher') - // const countDiscount = hitungDiscountVoucher(activeVoucher, 'voucher'); - - // SetDiscountVoucher(countDiscount); - // }, [activeVoucher, listVouchers]); - - // useEffect(() => { - // if (!listVoucherShippings) return; - // if (!activeVoucherShipping) return; - - // const countDiscount = hitungDiscountVoucher(activeVoucherShipping, 'voucher_shipping'); - - // SetDiscountVoucherOngkir(countDiscount); - // }, [activeVoucherShipping, listVoucherShippings]); - useEffect(() => { if (qVoucher === 'PASTIHEMAT' && listVouchers) { let code = qVoucher; @@ -381,71 +337,6 @@ const Checkout = () => { setHasFlashSale(hasFlashSale); }, [cartCheckout]); - useEffect(() => { - setCheckoutValidation(false); - const loadServiceRajaOngkir = async () => { - setLoadingRajaOngkir(true); - const body = { - origin: 2127, - destination: selectedAddress.shipping.rajaongkirCityId, - weight: totalWeight, - courier: selectedCarrier, - originType: 'subdistrict', - destinationType: 'subdistrict', - }; - setBiayaKirim(0); - const dataService = await axios( - '/api/rajaongkir-service?body=' + JSON.stringify(body) - ); - setLoadingRajaOngkir(false); - setListServiceExpedisi(dataService.data[0].costs); - if (dataService.data[0].costs[0]) { - setBiayaKirim(dataService.data[0].costs[0]?.cost[0].value); - setselectedExpedisiService( - dataService.data[0].costs[0]?.description + - '-' + - dataService.data[0].costs[0]?.service - ); - setEtd(dataService.data[0].costs[0]?.cost[0].etd); - toast.success('Harap pilih tipe layanan pengiriman'); - } else { - toast.error('Maaf, layanan tidak tersedia. Mohon pilih expedisi lain.'); - } - }; - if (selectedCarrier != 0 && selectedCarrier != 1 && totalWeight > 0) { - loadServiceRajaOngkir(); - } else { - setListServiceExpedisi(); - setBiayaKirim(0); - setselectedExpedisiService(); - setEtd(); - } - }, [selectedCarrier, selectedAddress, totalWeight]); - - useEffect(() => { - if (selectedServiceType) { - let serviceType = selectedServiceType.split(','); - setBiayaKirim(serviceType[0]); - setselectedExpedisiService(serviceType[1]); - setEtd(serviceType[2]); - } - }, [selectedServiceType]); - - useEffect(() => { - if (etd) setEtdFix(calculateEstimatedArrival(etd)); - }, [etd]); - - useEffect(() => { - if (selectedExpedisi) { - let serviceType = selectedExpedisi.split(','); - if (serviceType[0] === 0) return; - - setselectedCarrier(serviceType[0]); - setselectedCarrierId(serviceType[1]); - setListServiceExpedisi([]); - } - }, [selectedExpedisi]); - const poNumber = useRef(null); const poFile = useRef(null); @@ -472,7 +363,7 @@ const Checkout = () => { }); return; } - if (selectedExpedisi === 0) { + if (selectedCourier === 0 || !selectedCourier) { setCheckoutValidation(true); if (expedisiValidation.current) { const position = expedisiValidation.current.getBoundingClientRect(); @@ -483,9 +374,17 @@ const Checkout = () => { } return; } - if (selectedCarrier != 1 && biayaKirim == 0) { - toast.error('Maaf, layanan tidak tersedia. Mohon pilih expedisi 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) => ({ @@ -493,23 +392,37 @@ const Checkout = () => { quantity: product.quantity, available_quantity: product?.availableQuantity, })); + + let eta_courier = 0; + let eta_courier_start = 0; + + if (selectedCourierId !== SELF_PICKUP_ID && etd) { + const estimated_courier = etd.split('-').map(Number); + eta_courier = Math.max(...estimated_courier); + eta_courier_start = Math.min(...estimated_courier); + } + + // let estimated_courier = etd.split('-').map(Number); + // let eta_courier = Math.max(...estimated_courier); + // let eta_courier_start = Math.min(...estimated_courier); + let data = { - // partner_shipping_id: auth.partnerId, + // partner_shipping_id: auth.partnerId,, // partner_invoice_id: auth.partnerId, partner_shipping_id: selectedAddress?.shipping?.id || auth.partnerId, partner_invoice_id: selectedAddress?.invoicing?.id || auth.partnerId, user_id: auth.id, order_line: JSON.stringify(productOrder), delivery_amount: biayaKirim, - carrier_id: selectedCarrierId, - estimated_arrival_days: splitDuration(etd), - delivery_service_type: selectedExpedisiService, + carrier_id: selectedCourierId, + 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 voucher: activeVoucher, voucher_shipping: activeVoucherShipping, type: 'sale_order', }; - if (query) { data.source = 'buy'; } @@ -517,8 +430,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; @@ -570,24 +483,6 @@ const Checkout = () => { )}`; } } - - /* 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 handlingActivateCode = async () => { @@ -761,19 +656,6 @@ const Checkout = () => { )} <hr className='mt-8 mb-4 border-gray_r-8' /> - {/* {!loadingVoucher && - listVouchers?.length === 1 && - listVoucherShippings?.length === 1} - { - <div className='flex items-center justify-center mt-4 mb-4'> - <div className='text-center'> - <h1 className='font-bold mb-4'>Tidak ada voucher tersedia</h1> - <p className='text-gray-500'> - Maaf, saat ini tidak ada voucher yang tersedia. - </p> - </div> - </div> - } */} {listVoucherShippings && listVoucherShippings?.length > 0 && ( <div> @@ -1159,8 +1041,8 @@ const Checkout = () => { </Skeleton> )} <Divider /> - <SectionValidation address={selectedAddress.shipping} /> - <SectionExpedisi + <SectionValidation address={selectedAddress.invoicing} /> + {/* <SectionExpedisi address={selectedAddress.shipping} listExpedisi={listExpedisi} setSelectedExpedisi={setSelectedExpedisi} @@ -1173,7 +1055,7 @@ const Checkout = () => { <SectionListService listserviceExpedisi={listserviceExpedisi} setSelectedServiceType={setSelectedServiceType} - /> + /> */} <div className='p-4 flex flex-col gap-y-4'> {!!products && @@ -1188,7 +1070,7 @@ const Checkout = () => { </div> <Divider /> - + {products && <SectionExpedition products={products} />} <div className='p-4'> <div className='flex justify-between items-center'> <div className='font-medium'>Ringkasan Pesanan</div> @@ -1255,14 +1137,15 @@ 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'> <div className='text-gray_r-11'> - Biaya Kirim <p className='text-xs mt-1'>{etdFix}</p> + Biaya Kirim{' '} + <p className='text-xs mt-1'> + {formatShipmentRange(etd, unit, productSla)} + </p> </div> <div> {currencyFormat( @@ -1386,7 +1269,6 @@ const Checkout = () => { className='flex-1 btn-yellow' onClick={checkout} disabled={ - isLoading || !products || products?.length == 0 || priceCheck || @@ -1458,9 +1340,10 @@ const Checkout = () => { /> </Skeleton> )} + {products && <SectionExpedition products={products} />} <Divider /> - <SectionValidation address={selectedAddress.shipping} /> - <SectionExpedisi + <SectionValidation address={selectedAddress.invoicing} /> + {/* <SectionExpedisi address={selectedAddress.shipping} listExpedisi={listExpedisi} setSelectedExpedisi={setSelectedExpedisi} @@ -1473,7 +1356,7 @@ const Checkout = () => { <SectionListService listserviceExpedisi={listserviceExpedisi} setSelectedServiceType={setSelectedServiceType} - /> + /> */} <div className='p-4'> <div className='font-medium mb-6'>Detail Pesanan</div> @@ -1561,15 +1444,15 @@ 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'> <div className='text-gray_r-11'> Biaya Kirim - <p className='text-xs mt-1'>{etdFix}</p> + <p className='text-xs mt-1'> + {formatShipmentRange(etd, unit, productSla)} + </p> </div> <div> {currencyFormat( @@ -1691,7 +1574,6 @@ const Checkout = () => { className='w-full btn-yellow mt-4' onClick={checkout} disabled={ - isLoading || !products || products?.length == 0 || priceCheck || @@ -1740,14 +1622,29 @@ const SectionAddress = ({ address, label, url }) => ( <p className='mt-1 text-gray_r-11'> {address.street}, {address?.city?.name} </p> + <div className='flex gap-x-2 items-center mt-4 cursor-pointer'> + <MapPinIcon + className={ + address.addressMap + ? `h-6 w-6 text-gray-500` + : `h-6 w-6 text-red-500` + } + /> + {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> + )} + </div> </div> )} </div> ); const SectionValidation = ({ address }) => - address?.stateId == 0 || - (address?.rajaongkirCityId == 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.{' '} @@ -1761,17 +1658,17 @@ const SectionValidation = ({ address }) => </Link> </div> </BottomPopup> - )); + ); 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'> @@ -1823,9 +1720,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/SectionExpedition.jsx b/src/lib/checkout/components/SectionExpedition.jsx new file mode 100644 index 00000000..7a02c6e9 --- /dev/null +++ b/src/lib/checkout/components/SectionExpedition.jsx @@ -0,0 +1,505 @@ +import { Skeleton, Spinner } from '@chakra-ui/react'; +import axios from 'axios'; +import { AnimatePresence, motion } from 'framer-motion'; +import React, { useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useQuery } from 'react-query'; +import { useAddress } from '../stores/useAdress'; + +import currencyFormat from '@/core/utils/currencyFormat'; +import { useCheckout } from '../stores/stateCheckout'; +import { formatShipmentRange } from '../utils/functionCheckouit'; +import Image from 'next/image'; +import toast from 'react-hot-toast'; + +import odooApi from '@/core/api/odooApi'; +import { getProductsSla } from '../api/checkoutApi'; + +function mappingItems(products) { + return products?.map((item) => ({ + // name: item.parent.name || item?.name || 'Unknown Product', + name: item?.name, + description: `${item.code} - ${item.name}`, + value: item.price.priceDiscount, + weight: item.weight * 1000, + quantity: item.quantity, + })); +} + +function reverseMappingCourier(couriersOdoo, couriers, includeInstant = false) { + // Buat peta courier berdasarkan nama courier dari couriers + 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; + } + + if (!acc[key]) { + acc[key] = { + courier_name: item.courier_name, + courier_code: courier_code, + service_type: {}, + }; + } + + acc[key].service_type[courier_service_code] = { + 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, + description: item.description, + }; + + return acc; + }, {}); + + // Iterasi berdasarkan couriersOdoo + return couriersOdoo.map((courierOdoo) => { + const courierNameKey = courierOdoo.label.toLowerCase(); + const carrierId = courierOdoo.carrierId; + + const mappedCourier = courierMap[courierNameKey] || false; + + if (!mappedCourier) { + return { + ...courierOdoo, + courier: false, + }; + } + + return { + ...courierOdoo, + courier: { + ...mappedCourier, + courier_id_odoo: carrierId, + }, + }; + }); +} + +function mappingCourier(couriersOdoo, couriers, notIncludeInstant = false) { + const validCourierMap = couriersOdoo.reduce((acc, courier) => { + acc[courier.label.toLowerCase()] = courier.carrierId; + return acc; + }, {}); + + return couriers?.reduce((result, item) => { + const { courier_name, courier_code, courier_service_code } = item; + if (!validCourierMap[courier_name.toLowerCase()]) { + return result; // Jika tidak ada, lewati item ini + } + + if ( + notIncludeInstant && + ['hours'].includes(item.shipment_duration_unit.toLowerCase()) + ) { + return result; + } + + const carrierId = validCourierMap[courier_name]; + + // Jika courier_code belum ada di result, buat objek baru untuknya + if (!result[courier_code]) { + result[courier_code] = { + courier_name: item.courier_name, + courier_code: courier_code, + courier_id_odoo: carrierId, + service_type: { + [courier_service_code]: { + 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: item.service_type, + description: item.description, + }, + }, + }; + } else { + result[courier_code].service_type[courier_service_code] = { + service_name: item.courier_service_name, + duration: item.duration, + shipment_range: item.shipment_duration_range, + shipment_unit: item.shipment_duration_unit, + shipment_duration: item.duration, + price: item.price, + service_type: item.service_type, + description: item.description, + }; + } + + return result; + }, {}); +} + +// interface CourierService { +// courier_name: string; +// courier_code: string; +// service_type: { +// [key: string]: { +// service_name: string; +// duration: number; +// shipment_duration: number; +// price: number; +// service_type: string; +// description: string; +// }; +// }; +// } + +// interface ServiceOption { +// service_name: string; +// duration: number; +// shipment_duration: number; +// price: number; +// service_type: string; +// description: string; +// } + +export default function SectionExpedition({ products }) { + const { addressMaps, coordinate, postalCode } = useAddress(); + const [serviceOptions, setServiceOptions] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [onFocusSelectedCourier, setOnFocuseSelectedCourier] = useState(false); + const [couriers, setCouriers] = useState(null); + const [slaProducts, setSlaProducts] = useState(null); + const [savedServiceOptions, setSavedServiceOptions] = useState([]); + + const { + checkWeigth, + checkoutValidation, + setBiayaKirim, + setUnit, + setEtd, + selectedCourier, + setSelectedCourier, + selectedService, + setSelectedService, + listExpedisi, + productSla, + setProductSla, + setSelectedCourierId, + } = useCheckout(); + + let destination = {}; + let items = mappingItems(products); + + if (addressMaps) { + destination = { + origin_latitude: -6.3031123, + origin_longitude: 106.7794934999, + ...coordinate, + }; + } else if (postalCode) { + destination = { + origin_postal_code: 14440, + destination_postal_code: postalCode, + }; + } + + const fetchSlaProducts = async () => { + try { + let productsMapped = products.map((item) => ({ + id: item.id, + quantity: item.quantity, + })); + + let data = { + products: JSON.stringify(productsMapped), + } + const res = await odooApi('POST', `/api/v1/product/variants/sla`, data); + setSlaProducts(res); + } catch (error) { + console.error('Failed to fetch expedition rates:', error); + } + }; + + useEffect(() => { + fetchSlaProducts(); + }, []); + + useEffect(() => { + if (slaProducts) { + let productSla = slaProducts?.slaTotal; + if (slaProducts.slaUnit === 'jam') { + productSla = 1; + } + setProductSla(productSla); + } + }, [slaProducts]); + + const fetchExpedition = async () => { + let body = { + ...destination, + couriers: + 'gojek,grab,deliveree,lalamove,jne,tiki,ninja,lion,rara,sicepat,jnt,pos,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', + items: items, + }; + try { + const response = await axios.get(`/api/biteship-service`, { + params: { body: JSON.stringify(body) }, + }); + return response; + } catch (error) { + console.error('Failed to fetch expedition rates:', error); + } + }; + + const { data, isLoading } = useQuery( + ['expedition', JSON.stringify(destination), JSON.stringify(items)], + fetchExpedition, + { + enabled: + Boolean(Object.keys(destination).length) && + items?.length > 0 && + !checkWeigth && + onFocusSelectedCourier, + staleTime: Infinity, + cacheTime: Infinity, + } + ); + + useEffect(() => { + const instant = slaProducts?.includeInstant || false; + if (data) { + const couriers = reverseMappingCourier( + listExpedisi, + data?.data?.pricing, + instant + ); + setCouriers(couriers); + } + }, [data, slaProducts]); + + const onCourierChange = (code) => { + setIsOpen(false); + setOnFocuseSelectedCourier(false); + const courier = code; + setSelectedService(null); + setBiayaKirim(0); + if (courier !== 0 && courier !== 32) { + if (courier.courier) { + setSelectedCourier(courier.courier.courier_code); + setSelectedCourierId(courier.carrierId); + setServiceOptions(Object.values(courier.courier.service_type)); + } else { + if ( + (courier.label === 'GRAB' || courier.label === 'GOJEK') && + !addressMaps + ) { + toast.error( + 'Maaf, layanan kurir ' + + courier.label + + ' tidak tersedia. Karena Anda Belum Melakukan Pengaturan PinPoint Alamat Pegiriman.' + ); + } else { + toast.error( + 'Maaf, layanan tidak tersedia. Mohon pilih expedisi lain.' + ); + } + setServiceOptions([]); + } + } else { + setSelectedCourier(courier === 32 ? 'SELF PICKUP' : null); + setSelectedCourierId(courier); + setServiceOptions([]); + } + }; + + const handleOnFocuse = (value) => { + setOnFocuseSelectedCourier(!value); + setIsOpen(false); + }; + + const handleSelect = (service) => { + setSelectedService(service); + setBiayaKirim(service?.price); + setEtd(service?.shipment_range); + setUnit(service?.shipment_unit); + setIsOpen(false); + }; + + useEffect(() => { + if (serviceOptions.length > 0) { + setSavedServiceOptions(serviceOptions); + } +}, [serviceOptions]); + + return ( + <form > + <div className='px-4 py-2'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Pilih Ekspedisi: </div> + <div className='w-[350px] max'> + <div className='px-4 py-2'> + <div className='flex justify-between items-center'> + <div className='w-[450px]'> + <div className='relative'> + {/* Custom Select Input Field */} + <div + className='w-full p-2 border rounded-lg bg-white cursor-pointer' + onClick={() => handleOnFocuse(onFocusSelectedCourier)} + > + {selectedCourier ? ( + <div className='flex justify-between'> + <span>{selectedCourier}</span> + </div> + ) : ( + <span className='text-gray-500'>Pilih Expedisi</span> + )} + </div> + + {/* Dropdown Options */} + {onFocusSelectedCourier && ( + <div className='absolute w-full bg-white border rounded-lg mt-1 shadow-lg z-10 max-h-[200px] overflow-y-auto'> + {!isLoading ? ( + <> + <div + key={32} + onClick={() => onCourierChange(32)} + className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' + > + <div> + <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> + </div> + <span className='font-semibold'> + <Image + src={courier?.logo} + alt={courier?.courier?.courier_name} + width={50} + height={50} + /> + </span> + </div> + ))} + </> + ) : ( + <> + <Skeleton height={40} containerClassName='w-full' /> + <Skeleton height={40} containerClassName='w-full' /> + </> + )} + </div> + )} + </div> + </div> + </div> + </div> + {checkoutValidation && ( + <span className='text-sm text-red-500'> + *silahkan pilih expedisi + </span> + )} + </div> + <style jsx>{` + .shake { + animation: shake 0.4s ease-in-out; + } + `}</style> + </div> + {checkWeigth == true && ( + <p className='mt-4 text-gray_r-11 leading-6'> + Mohon maaf, pengiriman hanya tersedia untuk self pickup karena + terdapat barang yang belum diatur beratnya. Mohon atur berat barang + dengan menghubungi admin melalui{' '} + <a + className='text-danger-500 inline' + href='https://api.whatsapp.com/send?phone=6281717181922' + > + tautan ini + </a> + </p> + )} + </div> + + {(serviceOptions.length > 0 || + selectedService )&& + selectedCourier && + selectedCourier !== 32 && + selectedCourier !== 0 && ( + <div className='px-4 py-2'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Tipe Layanan Ekspedisi: </div> + <div className='w-[350px]'> + <div className='relative'> + {/* Custom Select Input Field */} + <div + className='w-full p-2 border rounded-lg bg-white cursor-pointer' + onClick={() => setIsOpen(!isOpen)} + > + {selectedService ? ( + <div className='flex justify-between'> + <span>{selectedService.service_name}</span> + <span className='font-semibold'> + {currencyFormat( + Math.round( + parseInt(selectedService?.price * 1.1) / 1000 + ) * 1000 + )} + </span> + </div> + ) : ( + <span className='text-gray-500'> + Pilih layanan pengiriman + </span> + )} + </div> + {isOpen && ( + <div className='absolute w-full bg-white border rounded-lg mt-1 shadow-lg z-10'> + {serviceOptions.map((service) => ( + <div + key={service.service_type} + onClick={() => handleSelect(service)} + className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' + > + <div> + <p className='font-semibold'> + {service.service_name} + </p> + <p className='text-gray-600 text-sm'> + {formatShipmentRange( + service.shipment_range, + service.shipment_unit, + productSla + )} + </p> + </div> + <span className='font-semibold'> + {currencyFormat( + Math.round( + parseInt(service?.price * 1.1) / 1000 + ) * 1000 + )} + </span> + </div> + ))} + </div> + )} + </div> + </div> + </div> + </div> + )} + </form> + ); +} diff --git a/src/lib/checkout/stores/stateCheckout.js b/src/lib/checkout/stores/stateCheckout.js new file mode 100644 index 00000000..52210d7f --- /dev/null +++ b/src/lib/checkout/stores/stateCheckout.js @@ -0,0 +1,30 @@ +import { create } from "zustand"; + +export const useCheckout = create((set) => ({ + products : null, + checkWeigth : false, + hasFlashSale : false, + checkoutValidation : false, + biayaKirim : 0, + etd : null, + unit : null, + selectedCourier : null, + selectedCourierId : null, + selectedService : null, + listExpedisi : [], + productSla : null, + setCheckWeight : (checkWeigth) => set({ checkWeigth }), + setHasFlashSale : (hasFlashSale) => set({ hasFlashSale }), + setCheckoutValidation : (checkoutValidation) => set({ checkoutValidation }), + setBiayaKirim : (biayaKirim) => set({ biayaKirim }), + setProducts : (products) => set({ products }), + setEtd : (etd) => set({ etd }), + setUnit : (unit) => set({ unit }), + setSelectedCourier : (selectedCourier) => set({ selectedCourier }), + setSelectedService : (selectedService) => set({ selectedService }), + setSelectedCourierId : (selectedCourierId) => set({ selectedCourierId }), + setExpedisi : (listExpedisi) => set({ listExpedisi }), + setProductSla : (productSla) => set({ productSla }) + + +}))
\ No newline at end of file diff --git a/src/lib/checkout/stores/useAdress.js b/src/lib/checkout/stores/useAdress.js new file mode 100644 index 00000000..5274ecfe --- /dev/null +++ b/src/lib/checkout/stores/useAdress.js @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +export const useAddress = create((set) => ({ + selectedAddress: { + shipping: null, + invoicing: null, + }, + addresses: null, + addressMaps : null, + coordinate : { + destination_latitude : null, + destination_longitude : null + }, + postalCode : null, + setAddresses: (addresses) => set({ addresses }), + setSelectedAddress: (selectedAddress) => set({ selectedAddress }), + setCoordinate: (coordinate) => set({ coordinate }), + setPostalCode: (postalCode) => set({ postalCode }), + setAddressMaps: (addressMaps) => set({ addressMaps }), + +})); diff --git a/src/lib/checkout/utils/functionCheckouit.js b/src/lib/checkout/utils/functionCheckouit.js new file mode 100644 index 00000000..a7fa8c5a --- /dev/null +++ b/src/lib/checkout/utils/functionCheckouit.js @@ -0,0 +1,92 @@ +import { m } from 'framer-motion'; +import { min } from 'moment/moment'; + +export function formatShipmentRange( + shipmentDurationRange, + shipmentDurationUnit, + productSLA +) { + if (!shipmentDurationRange || !shipmentDurationUnit) { + return ''; + } + let minRange, maxRange; + + console.log('ini masuk format shipment range', shipmentDurationRange, shipmentDurationUnit, productSLA); + + // Cek apakah durasi berupa range atau angka tunggal + if (shipmentDurationRange.includes('-')) { + [minRange, maxRange] = shipmentDurationRange.split(' - ').map(Number); + // if (minRange === maxRange) { + // maxRange = minRange + 3; + // } + } else { + minRange = Number(shipmentDurationRange); // Jika angka tunggal + maxRange = Number(shipmentDurationRange); + } + + const start = new Date(); // Tanggal saat ini + + let minDate, maxDate; + + // Hitung estimasi berdasarkan unit waktu + if (shipmentDurationUnit === 'days' || shipmentDurationUnit === 'day') { + minDate = new Date(start); + minDate.setDate(start.getDate() + (minRange + productSLA)); + + maxDate = new Date(start); + maxDate.setDate(start.getDate() + (maxRange + productSLA)); + } else if (shipmentDurationUnit === 'hours') { + minDate = new Date(start); + minDate.setDate(start.getDate() + (1 + productSLA)); + + maxDate = new Date(start); + maxDate.setDate(start.getDate() + (1 + productSLA + 1)); + // minDate = new Date(start.getTime() + (minRange + 3) * 60 * 60 * 1000); + // maxDate = new Date(start.getTime() + (maxRange + 3) * 60 * 60 * 1000); + } else { + throw new Error("Unsupported unit. Please use 'days' or 'hours'."); + } + + const minDateStr = formatDate(minDate); + const maxDateStr = formatDate(maxDate); + if (minDateStr === maxDateStr) { + return `Estimasi tiba ${minDateStr}`; + } + return `Estimasi tiba ${minDateStr} - ${maxDateStr}`; +} + +export function getToDate(shipmentDurationRange, shipmentDurationUnit) { + if (!shipmentDurationRange || !shipmentDurationUnit) { + return ''; + } + const start = new Date(); // Tanggal saat ini + + let maxRange; + + // Cek apakah durasi berupa range atau angka tunggal + if (shipmentDurationRange.includes('-')) { + [, maxRange] = shipmentDurationRange.split(' - ').map(Number); + } else { + maxRange = Number(shipmentDurationRange); // Jika angka tunggal + } + + let maxDate; + + // Hitung estimasi berdasarkan unit waktu + if (shipmentDurationUnit === 'days' || shipmentDurationUnit === 'day') { + maxDate = new Date(start); + maxDate.setDate(start.getDate() + (maxRange + 3)); + } else if (shipmentDurationUnit === 'hours') { + maxDate = new Date(start.getTime() + (maxRange + 3) * 60 * 60 * 1000); + } else { + throw new Error("Unsupported unit. Please use 'days' or 'hours'."); + } + + return maxDate.getDate(); +} + +function formatDate(date) { + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'short' }); + return `${day} ${month}`; +} diff --git a/src/lib/home/components/BannerSection.jsx b/src/lib/home/components/BannerSection.jsx index 898f1bf5..1eac9592 100644 --- a/src/lib/home/components/BannerSection.jsx +++ b/src/lib/home/components/BannerSection.jsx @@ -1,62 +1,126 @@ import Link from '@/core/components/elements/Link/Link'; -import Image from 'next/image'; import { useEffect, useState } from 'react'; -import { bannerApi } from '../../../api/bannerApi'; - -const { useQuery } = require('react-query'); -const { default: bannerSectionApi } = require('../api/bannerSectionApi'); +import useDevice from '@/core/hooks/useDevice'; +import { Swiper, SwiperSlide } from 'swiper/react'; const BannerSection = () => { - const [data, setData] = useState(null); - const [shouldFetch, setShouldFetch] = useState(false); - + const [privateBrandData, setPrivateBrandData] = useState([]); + const [homeBannerData, setHomeBannerData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { isMobile, isDesktop } = useDevice(); useEffect(() => { - const fetchCategoryData = async () => { - const res = await fetch('/api/banner-section'); - const { data } = await res.json(); - if (data) { - setData(data); + const fetchAllBanners = async () => { + try { + // Fetch private brand banners + const privateBrandRes = await fetch( + '/api/banner-section?type=private-brand' + ); + if (privateBrandRes.ok) { + const privateBrandResult = await privateBrandRes.json(); + setPrivateBrandData(privateBrandResult.data || []); + } + + // Fetch home banners + const homeBannerRes = await fetch( + '/api/banner-section?type=home-banner' + ); + if (homeBannerRes.ok) { + const homeBannerResult = await homeBannerRes.json(); + setHomeBannerData(homeBannerResult.data || []); + } + } catch (err) { + setError('Network error'); + } finally { + setLoading(false); } }; - fetchCategoryData(); + fetchAllBanners(); }, []); - // const fetchBannerSection = async () => await bannerSectionApi(); - const getBannerSection = useQuery( - 'bannerSection', - bannerApi({ type: 'home-banner' }), - { - enabled: shouldFetch, - onSuccess: (data) => { - if (data) { - localStorage.setItem('Homepage_bannerSection', JSON.stringify(data)); - setData(data); - } - }, - } - ); + // if (loading) return <div>Loading...</div>; + // if (error) return <div>Error: {error}</div>; - const bannerSection = data; return ( - bannerSection && - bannerSection?.length > 0 && ( - <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> - {bannerSection?.map((banner) => ( - <Link key={banner.id} href={banner.url}> - <Image - width={1024} - height={512} - quality={85} - src={banner.image} - alt={banner.name} - className='h-auto w-full rounded' - loading='eager' - /> - </Link> - ))} - </div> - ) + <div className='space-y-12'> + {/* Private Brand Section */} + {privateBrandData && privateBrandData.length > 0 && ( + <div className='px-4 sm:px-0'> + <div + className='text-black font-semibold sm:text-h-lg mb-6' + id='private-brand' + > + Private Brand + </div> + + {/* Desktop Grid View */} + {isDesktop && ( + <div className='grid grid-cols-3 sm:grid-cols-3 gap-4 rounded-md'> + {privateBrandData.map((banner, index) => ( + <Link key={banner.id || index} href={banner.url || '#'}> + <img + width={439} + height={150} + quality={85} + src={banner.image} + alt={banner.name || `Private Brand Banner ${index + 1}`} + className='rounded hover:scale-105 transition duration-500 ease-in-out' + loading='eager' + /> + </Link> + ))} + </div> + )} + + {/* Mobile Swiper View */} + {isMobile && ( + <Swiper slidesPerView={1.1} spaceBetween={8} freeMode> + {privateBrandData.map((banner, index) => ( + <SwiperSlide key={banner.id || index}> + <Link href={banner.url || '#'}> + <img + width={350} + height={100} + quality={70} + src={banner.image} + alt={banner.name || `Private Brand Banner ${index + 1}`} + className='rounded' + loading='eager' + /> + </Link> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + )} + + {/* Home Banner Section */} + {homeBannerData && homeBannerData.length > 0 && ( + <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> + {homeBannerData.map((banner, index) => ( + <div key={banner.id || index} className='relative'> + <Link href={banner.url || '#'}> + <img + width={1024} + height={512} + quality={85} + src={banner.image} + alt={banner.name || `Home Banner ${index + 1}`} + // className='h-40 w-full rounded object-cover transition-transform hover:scale-105' + className='h-auto w-full rounded' + loading='eager' + onError={(e) => { + e.target.style.display = 'none'; + }} + /> + </Link> + </div> + ))} + </div> + )} + </div> ); }; diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx index 9f436dac..db473a1c 100644 --- a/src/lib/home/components/CategoryHomeId.jsx +++ b/src/lib/home/components/CategoryHomeId.jsx @@ -8,7 +8,7 @@ const CategoryHomeId = () => { return ( <div> <h1 className='font-semibold text-[14px] sm:text-h-lg mb-6 px-4 sm:px-0'> - Kategori Pilihan + Paket Bundling / Paket UMKM </h1> <div className='flex flex-col gap-y-10'> {categoryHomeIds.data?.map((id) => ( diff --git a/src/lib/home/components/PopupBannerPromotion.jsx b/src/lib/home/components/PopupBannerPromotion.jsx new file mode 100644 index 00000000..538f35e6 --- /dev/null +++ b/src/lib/home/components/PopupBannerPromotion.jsx @@ -0,0 +1,260 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState, useRef } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { X } from 'lucide-react'; +import { getAuth } from '~/libs/auth'; +import useDevice from '@/core/hooks/useDevice'; +import { createPortal } from 'react-dom'; + +const PagePopupInformation = () => { + const router = useRouter(); + const isHomePage = router.pathname === '/'; + const auth = getAuth(); + const { isDesktop } = useDevice(); + + const [active, setActive] = useState(false); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const popupRef = useRef(null); + const [position, setPosition] = useState({ x: 20, y: window.innerHeight - 170 }); + const dragStartPos = useRef({ x: 0, y: 0 }); + const isDragging = useRef(false); + const isTouching = useRef(false); + const [isSnapping, setIsSnapping] = useState(false); + const [containerLeft, setContainerLeft] = useState(0); + + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + if (isHomePage && !auth) { + setActive(true); + fetchData(); + } + }, [isHomePage, auth]); + + const fetchData = async () => { + try { + const res = await fetch(`/api/hero-banner?type=dragable-banner`); + const { data } = await res.json(); + if (Array.isArray(data) && data[0]?.image) { + setData(data[0]); + } else { + setActive(false); + } + } catch (error) { + console.error('Failed to fetch popup banner:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + const handleResizeOrZoom = () => { + if (!popupRef.current) return; + + const popupWidth = popupRef.current.offsetWidth || 85; + const popupHeight = popupRef.current.offsetHeight || 85; + + setPosition({ + x: 20, + y: window.innerHeight - popupHeight - 20, + }); + }; + + window.addEventListener('resize', handleResizeOrZoom); + + return () => { + window.removeEventListener('resize', handleResizeOrZoom); + }; + }, []); + + + const updateContainerLeft = () => { + const container = document.querySelector('.container'); + if (container) { + setContainerLeft(container.getBoundingClientRect().left); + } + }; + + useEffect(() => { + updateContainerLeft(); + window.addEventListener('resize', updateContainerLeft); + window.addEventListener('scroll', updateContainerLeft); + return () => { + window.removeEventListener('resize', updateContainerLeft); + window.removeEventListener('scroll', updateContainerLeft); + }; + }, []); + + useEffect(() => { + if (isDesktop && containerLeft) { + const popupWidth = popupRef.current?.offsetWidth || 85; + setPosition({ + x: containerLeft - popupWidth - 20, + y: window.innerHeight - 130, + }); + } + }, [isDesktop, containerLeft]); + + const startDrag = (clientX, clientY) => { + dragStartPos.current = { x: clientX - position.x, y: clientY - position.y }; + isDragging.current = false; + setIsSnapping(false); + }; + + const moveDrag = (clientX, clientY) => { + isDragging.current = true; + const popupWidth = popupRef.current?.offsetWidth || 85; + const popupHeight = popupRef.current?.offsetHeight || 85; + const maxX = window.innerWidth - popupWidth - 20; + + const topPadding = isDesktop ? 0 : 130; + const bottomPadding = isDesktop ? 0 : 80; + const maxY = window.innerHeight - popupHeight - bottomPadding; + + const minX = 0; + + let newX = clientX - dragStartPos.current.x; + let newY = clientY - dragStartPos.current.y; + + newX = Math.max(minX, Math.min(newX, maxX)); + newY = Math.max(topPadding, Math.min(newY, maxY)); + + setPosition({ x: newX, y: newY }); + }; + + const endDrag = () => { + if (isDragging.current) { + setIsSnapping(true); + + if (isDesktop) { + const popupWidth = popupRef.current?.offsetWidth || 85; + setPosition({ + x: containerLeft - popupWidth - 20, + y: window.innerHeight - 130, + }); + } else { + const popupWidth = popupRef.current?.offsetWidth || 85; + const screenMiddle = window.innerWidth / 2; + + setPosition(pos => ({ + x: pos.x + popupWidth / 2 < screenMiddle ? 20 : window.innerWidth - popupWidth - 20, + y: pos.y, + })); + } + } + isDragging.current = false; + isTouching.current = false; + }; + + + + const handleMouseDown = e => { + e.preventDefault(); + startDrag(e.clientX, e.clientY); + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseMove = e => moveDrag(e.clientX, e.clientY); + + const handleMouseUp = () => { + endDrag(); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + + const handleTouchStart = e => { + if (e.touches.length === 1) { + isTouching.current = true; + startDrag(e.touches[0].clientX, e.touches[0].clientY); + } + }; + + useEffect(() => { + const handleTouchMove = e => { + if (isTouching.current) { + e.preventDefault(); + moveDrag(e.touches[0].clientX, e.touches[0].clientY); + } + }; + + const handleTouchEnd = () => endDrag(); + + window.addEventListener('touchmove', handleTouchMove, { passive: false }); + window.addEventListener('touchend', handleTouchEnd); + return () => { + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, []); + + if (!active || !data || loading) return null; + + const popupContent = ( + <div + ref={popupRef} + className={`relative pointer-events-auto ${isSnapping ? 'transition-transform duration-300 ease-out' : ''}`} + style={{ + transform: `translate(${position.x}px, ${position.y}px)`, + cursor: 'grab', + width: '85px', + }} + onMouseDown={handleMouseDown} + onTouchStart={handleTouchStart} + > + <Link + href={typeof data.url === 'boolean' && data.url === false ? '/' : data.url} + onClick={e => { + if (isDragging.current) { + e.preventDefault(); + isDragging.current = false; + } else { + setActive(false); + } + }} + draggable="false" + > + <Image + src={data.image} + alt={data.name || 'popup'} + width={85} + height={85} + className="w-full h-auto select-none" + draggable="false" + /> + </Link> + + <button + onClick={() => setActive(false)} + className="absolute -top-2 -right-2 z-10 p-1 bg-red-500 rounded-full hover:bg-red-600 transition-colors" + aria-label="Close popup" + > + <X className="w-3 h-3 text-white" /> + </button> + </div> + ); + + if (isDesktop && isClient) { + return createPortal( + <div className="fixed z-[9999] pointer-events-none top-0 left-0 w-screen h-screen"> + {popupContent} + </div>, + document.body + ); + } + + return ( + <div className="fixed z-[9999] pointer-events-none top-[40px] left-0"> + {popupContent} + </div> + ); +}; + +export default PagePopupInformation; diff --git a/src/lib/invoice/components/Invoice.jsx b/src/lib/invoice/components/Invoice.jsx index 15bfa746..a26b231f 100644 --- a/src/lib/invoice/components/Invoice.jsx +++ b/src/lib/invoice/components/Invoice.jsx @@ -1,60 +1,66 @@ -import Spinner from '@/core/components/elements/Spinner/Spinner' -import useInvoice from '../hooks/useInvoice' -import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' -import Divider from '@/core/components/elements/Divider/Divider' -import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' -import currencyFormat from '@/core/utils/currencyFormat' -import MobileView from '@/core/components/views/MobileView' -import DesktopView from '@/core/components/views/DesktopView' -import Menu from '@/lib/auth/components/Menu' -import Link from '@/core/components/elements/Link/Link' -import Image from '@/core/components/elements/Image/Image' -import { createSlug } from '@/core/utils/slug' -import { useEffect, useState } from 'react' +import Spinner from '@/core/components/elements/Spinner/Spinner'; +import useInvoice from '../hooks/useInvoice'; +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices'; +import Divider from '@/core/components/elements/Divider/Divider'; +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard'; +import currencyFormat from '@/core/utils/currencyFormat'; +import MobileView from '@/core/components/views/MobileView'; +import DesktopView from '@/core/components/views/DesktopView'; +import Menu from '@/lib/auth/components/Menu'; +import Link from '@/core/components/elements/Link/Link'; +import Image from '@/core/components/elements/Image/Image'; +import { createSlug } from '@/core/utils/slug'; +import { useEffect, useState } from 'react'; const Invoice = ({ id }) => { - const PPN = process.env.NEXT_PUBLIC_PPN - const { invoice } = useInvoice({ id }) + const PPN = process.env.NEXT_PUBLIC_PPN; + const { invoice } = useInvoice({ id }); - const [totalAmount, setTotalAmount] = useState(0) - const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + const [totalAmount, setTotalAmount] = useState(0); + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0); + + const amountBeforePPN = invoice.data?.amountTotal / PPN; + const taxAmount = invoice.data?.amountTotal - amountBeforePPN; useEffect(() => { if (invoice?.data?.products) { - let calculateTotalAmount = 0 - let calculateTotalDiscountAmount = 0 + let calculateTotalAmount = 0; + let calculateTotalDiscountAmount = 0; invoice.data.products.forEach((product) => { - calculateTotalAmount += product.price.price * product.quantity + calculateTotalAmount += product.price.price * product.quantity; calculateTotalDiscountAmount += - (product.price.price - product.price.priceDiscount) * product.quantity - }) - setTotalAmount(calculateTotalAmount) - setTotalDiscountAmount(calculateTotalDiscountAmount) + (product.price.price - product.price.priceDiscount) * + product.quantity; + }); + setTotalAmount(calculateTotalAmount); + setTotalDiscountAmount(calculateTotalDiscountAmount); } - }, [invoice]) + }, [invoice]); if (invoice.isLoading) { return ( <div className='flex justify-center my-6'> <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> </div> - ) + ); } - const address = invoice.data?.customer - let fullAddress = [] - if (address?.street) fullAddress.push(address.street) - if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) - if (address?.district?.name) fullAddress.push(address.district.name) - if (address?.city?.name) fullAddress.push(address.city.name) - fullAddress = fullAddress.join(', ') + const address = invoice.data?.customer; + let fullAddress = []; + if (address?.street) fullAddress.push(address.street); + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name); + if (address?.district?.name) fullAddress.push(address.district.name); + if (address?.city?.name) fullAddress.push(address.city.name); + fullAddress = fullAddress.join(', '); return ( invoice.data?.name && ( <> <MobileView> <div className='flex flex-col gap-y-4 p-4'> - <DescriptionRow label='No Invoice'>{invoice.data?.name}</DescriptionRow> + <DescriptionRow label='No Invoice'> + {invoice.data?.name} + </DescriptionRow> <DescriptionRow label='Status Transaksi'> {invoice.data?.amountResidual > 0 ? ( <span className='badge-solid-red'>Belum Lunas</span> @@ -68,13 +74,18 @@ const Invoice = ({ id }) => { <DescriptionRow label='Ketentuan Pembayaran'> {invoice.data?.paymentTerm} </DescriptionRow> - {invoice.data?.amountResidual > 0 && invoice.invoiceDate != invoice.invoiceDateDue && ( - <DescriptionRow label='Tanggal Jatuh Tempo'> - {invoice.data?.invoiceDateDue} - </DescriptionRow> - )} - <DescriptionRow label='Nama Sales'>{invoice.data?.sales}</DescriptionRow> - <DescriptionRow label='Tanggal Invoice'>{invoice.data?.invoiceDate}</DescriptionRow> + {invoice.data?.amountResidual > 0 && + invoice.invoiceDate != invoice.invoiceDateDue && ( + <DescriptionRow label='Tanggal Jatuh Tempo'> + {invoice.data?.invoiceDateDue} + </DescriptionRow> + )} + <DescriptionRow label='Nama Sales'> + {invoice.data?.sales} + </DescriptionRow> + <DescriptionRow label='Tanggal Invoice'> + {invoice.data?.invoiceDate} + </DescriptionRow> <div className='flex items-center'> <p className='text-gray_r-11 leading-none'>Invoice</p> <button @@ -104,8 +115,12 @@ const Invoice = ({ id }) => { <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> <DescriptionRow label='Nama'>{address?.name}</DescriptionRow> - <DescriptionRow label='Email'>{address?.email || '-'}</DescriptionRow> - <DescriptionRow label='No Telepon'>{address?.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Email'> + {address?.email || '-'} + </DescriptionRow> + <DescriptionRow label='No Telepon'> + {address?.mobile || '-'} + </DescriptionRow> <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> </div> @@ -128,10 +143,14 @@ const Invoice = ({ id }) => { <Menu /> </div> <div className='w-9/12 p-4 bg-white border border-gray_r-6 rounded'> - <h1 className='text-title-sm font-semibold mb-6'>Invoice & Faktur Pajak</h1> + <h1 className='text-title-sm font-semibold mb-6'> + Invoice & Faktur Pajak + </h1> <div className='flex items-center gap-x-2 mb-3'> - <span className='text-h-sm font-medium'>{invoice?.data?.name}</span> + <span className='text-h-sm font-medium'> + {invoice?.data?.name} + </span> {invoice?.data?.amountResidual > 0 ? ( <div className='badge-solid-red h-fit'>Belum Lunas</div> ) : ( @@ -180,14 +199,16 @@ const Invoice = ({ id }) => { </div> </div> - <div className='text-h-sm font-semibold mt-6 mb-4'>Rincian Pembelian</div> + <div className='text-h-sm font-semibold mt-6 mb-4'> + Rincian Pembelian + </div> <table className='table-data'> <thead> <tr> <th>Nama Produk</th> <th>Jumlah</th> <th>Harga</th> - <th>Diskon</th> + {/* <th>Diskon</th> */} <th>Subtotal</th> </tr> </thead> @@ -229,13 +250,22 @@ const Invoice = ({ id }) => { </div> </td> <td>{product.quantity}</td> - <td>{currencyFormat(product.price.price)}</td> <td> + {currencyFormat( + product.price.priceDiscount - taxAmount + )} + </td> + {/* <td> {product.price.discountPercentage > 0 ? `${product.price.discountPercentage}%` : ''} + </td> */} + <td> + {currencyFormat( + product.price.priceDiscount * product.quantity - + taxAmount + )} </td> - <td>{currencyFormat(product.price.priceDiscount * product.quantity)}</td> </tr> ))} </tbody> @@ -244,20 +274,30 @@ const Invoice = ({ id }) => { <div className='flex justify-end mt-4'> <div className='w-1/4 grid grid-cols-2 gap-y-2 text-gray_r-12/80'> <div className='text-right'>Subtotal</div> - <div className='text-right font-medium'>{currencyFormat(totalAmount)}</div> + <div className='text-right font-medium'> + {currencyFormat( + totalAmount - totalDiscountAmount - taxAmount + )} + </div> - <div className='text-right'>Total Diskon</div> + {/* <div className='text-right'>Total Diskon</div> <div className='text-right font-medium'> - {currencyFormat(-totalDiscountAmount)} + {currencyFormat(totalDiscountAmount)} + </div> */} + <div className='text-right'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </div> + <div className='text-right font-medium'> + {currencyFormat( + invoice.data?.amountTotal - + invoice.data?.amountTotal / PPN + )} </div> <div className='text-right'>Grand Total</div> <div className='text-right font-medium text-gray_r-12'> {currencyFormat(invoice.data?.amountTotal)} </div> - - <div className='text-right'>PPN {((PPN - 1) * 100).toFixed(0)}% (Incl.)</div> - <div className='text-right font-medium'>{currencyFormat(invoice.data?.amountTotal - totalAmount)}</div> </div> </div> </div> @@ -265,14 +305,14 @@ const Invoice = ({ id }) => { </DesktopView> </> ) - ) -} + ); +}; const DescriptionRow = ({ children, label }) => ( <div className='grid grid-cols-2'> <span className='text-gray_r-11'>{label}</span> <span className='text-right'>{children}</span> </div> -) +); -export default Invoice +export default Invoice; diff --git a/src/lib/maps/components/PinPointMap.jsx b/src/lib/maps/components/PinPointMap.jsx new file mode 100644 index 00000000..c46d838a --- /dev/null +++ b/src/lib/maps/components/PinPointMap.jsx @@ -0,0 +1,226 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { + GoogleMap, + useJsApiLoader, + Marker, + Autocomplete, +} from '@react-google-maps/api'; +import { useMaps } from '../stores/useMaps'; +import { LocateFixed, MapPinIcon } from 'lucide-react'; +import { Button } from '@chakra-ui/react'; + +const containerStyle = { + width: '100%', + height: '400px', +}; + +const defaultCenter = { + lat: -6.2, + lng: 106.816666, +}; + +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, + } = useMaps(); + + const [tempAddress, setTempAddress] = useState(initialAddress || ''); + const [tempPosition, setTempPosition] = useState( + initialLatitude && initialLongitude + ? { lat: parseFloat(initialLatitude), lng: parseFloat(initialLongitude) } + : selectedPosition.lat && selectedPosition.lng + ? selectedPosition + : defaultCenter + ); + + const [markerIcon, setMarkerIcon] = useState(null); + + const autocompleteRef = useRef(null); + + useEffect(() => { + if (isLoaded && window.google) { + setMarkerIcon({ + url: 'https://cdn.pixabay.com/photo/2014/04/03/10/03/google-309740_1280.png', + scaledSize: new window.google.maps.Size(25, 40), + }); + } + + // If we have initial coordinates but no address, fetch the address + if (initialLatitude && initialLongitude && !initialAddress) { + getAddress(parseFloat(initialLatitude), parseFloat(initialLongitude)); + } + }, [isLoaded, initialLatitude, initialLongitude, initialAddress]); + + const getAddressComponent = (components, type) => { + const component = components.find((comp) => comp.types.includes(type)); + return component ? component.long_name : ''; + }; + + 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}` + ); + const data = await response.json(); + + if (data.results[0]) { + const addressComponents = data.results[0].address_components; + const formattedAddress = data.results[0].formatted_address; + + const details = { + 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'), + postalCode: getAddressComponent(addressComponents, 'postal_code'), + }; + + setDetailAddress(details); + setTempAddress(formattedAddress); + } + } catch (error) { + console.error('Error fetching address:', error); + } + }; + + const onMapClick = useCallback((event) => { + const lat = event.latLng.lat(); + const lng = event.latLng.lng(); + const newPosition = { lat, lng }; + setTempPosition(newPosition); + getAddress(lat, lng); + }, []); + + const handlePlaceSelect = () => { + const place = autocompleteRef.current.getPlace(); + if (place && place.geometry) { + const lat = place.geometry.location.lat(); + const lng = place.geometry.location.lng(); + const newPosition = { lat, lng }; + setTempPosition(newPosition); + setTempAddress(place.formatted_address); + getAddress(lat, lng); + } + }; + + const handleUseCurrentLocation = () => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lng = position.coords.longitude; + const newPosition = { lat, lng }; + setTempPosition(newPosition); + getAddress(lat, lng); + }, + (error) => { + console.error('Error getting current location:', error); + } + ); + } + }; + + const handleSavePinpoint = (event) => { + event.preventDefault(); + if (tempAddress === '') { + alert('Silahkan pilih lokasi terlebih dahulu'); + return; + } + + setSelectedPosition(tempPosition); + setAddressMaps(tempAddress); + setPinedMaps(false); + }; + + return ( + <div className='w-full'> + <h3>Tentukan Pinpoint Lokasi</h3> + + <div style={{ marginBottom: '10px' }}> + {isLoaded ? ( + <Autocomplete + onLoad={(ref) => (autocompleteRef.current = ref)} + onPlaceChanged={handlePlaceSelect} + > + <input + type='text' + placeholder='Cari Alamat...' + value={tempAddress} + onChange={(e) => setTempAddress(e.target.value)} + style={{ width: '100%', padding: '8px' }} + /> + </Autocomplete> + ) : ( + <p>Loading autocomplete...</p> + )} + </div> + + <div> + {isLoaded ? ( + <GoogleMap + mapContainerStyle={containerStyle} + center={tempPosition} + zoom={15} + onClick={onMapClick} + > + {markerIcon && ( + <Marker + position={tempPosition} + draggable={true} + onDragEnd={(e) => { + const lat = e.latLng.lat(); + const lng = e.latLng.lng(); + const newPosition = { lat, lng }; + setTempPosition(newPosition); + getAddress(lat, lng); + }} + icon={markerIcon} + /> + )} + </GoogleMap> + ) : ( + <p>Loading map...</p> + )} + </div> + + <div style={{ marginTop: '20px' }}> + <Button variant='solid' onClick={handleUseCurrentLocation}> + <LocateFixed className='h-6 w-6 text-gray-500 mr-2' /> Gunakan Lokasi Saat Ini + </Button> + </div> + + <div style={{ marginTop: '10px' }}> + <p>PinPoint :</p> + <div className='flex gap-x-2 shadow-md rounded-sm text-gray-500 p-3 items-center'> + <MapPinIcon className='h-8 w-8 text-gray-500 mr-3' /> + <label>{tempAddress || 'Pilih lokasi di peta'}</label> + </div> + </div> + + <div className='mt-6 flex justify-end'> + <button + className='p-3 border border-red-500 bg-red-600 text-white font-semibold rounded-lg' + onClick={handleSavePinpoint} + > + Simpan Lokasi Ini + </button> + </div> + </div> + ); +}; + +export default PinpointLocation;
\ No newline at end of file diff --git a/src/lib/maps/stores/useMaps.js b/src/lib/maps/stores/useMaps.js new file mode 100644 index 00000000..c57a05ad --- /dev/null +++ b/src/lib/maps/stores/useMaps.js @@ -0,0 +1,32 @@ +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, + addressMaps: '', + detailAddress: {}, + pinedMaps: false, + + // State tambahan untuk penyimpanan posisi sementara + tempPositionCreate: null, + tempPositionEdit: null, + + // Setter existing + 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 }), +})); diff --git a/src/lib/pengajuan-tempo/component/FinishTempo.jsx b/src/lib/pengajuan-tempo/component/FinishTempo.jsx index bfcd0909..aacb9ef3 100644 --- a/src/lib/pengajuan-tempo/component/FinishTempo.jsx +++ b/src/lib/pengajuan-tempo/component/FinishTempo.jsx @@ -95,14 +95,20 @@ const FinishTempo = ({ query }) => { '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` : `/my/tempo/`} + 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' + {query?.status === 'switch-account' ? 'Ubah Akun' - : query?.status == 'approve' + : query?.status === 'approve' ? 'Lihat Detail Tempo' - : 'Lihat Status Pendaftaran'} + : '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 7cf201b7..d59bfd75 100644 --- a/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx +++ b/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx @@ -527,7 +527,7 @@ const PengajuanTempo = () => { toast.dismiss(toastId); setIsLoading(false); - toast.error('Terjadi kesalahan dalam pengiriman formulir hehehehe'); + toast.error('Terjadi kesalahan dalam pengiriman formulir'); console.error(error); } }; diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index eb86485d..2fb3138a 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -498,7 +498,7 @@ const ProductSearch = ({ <Pagination pageCount={pageCount} currentPage={parseInt(page)} - url={`${prefixUrl}?${toQuery(_.omit(query, ['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' /> @@ -688,7 +688,7 @@ const ProductSearch = ({ <Pagination pageCount={pageCount} currentPage={parseInt(page)} - url={`${prefixUrl}?${toQuery(_.omit(query, ['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='!justify-end' /> diff --git a/src/lib/shipment/components/Shipments.jsx b/src/lib/shipment/components/Shipments.jsx index 20dbb013..aaf778c3 100644 --- a/src/lib/shipment/components/Shipments.jsx +++ b/src/lib/shipment/components/Shipments.jsx @@ -251,7 +251,7 @@ const Shipments = () => { <tr> <th>Tanggal</th> <th>No. Resi</th> - <th>No. Pengiriman</th> + <th>No. Dokumen</th> <th>Sales Order</th> <th>Purchase Order</th> <th>Expedisi</th> diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx index 9216f7f7..2c1a6208 100644 --- a/src/lib/transaction/components/Transaction.jsx +++ b/src/lib/transaction/components/Transaction.jsx @@ -46,6 +46,8 @@ import { downloadTaxInvoice, } from '@/lib/invoice/utils/invoices'; import axios from 'axios'; +import InformationSection from '../../treckingAwb/component/InformationSection'; +import { Button } from '@chakra-ui/react'; const Transaction = ({ id }) => { const PPN = process.env.NEXT_PUBLIC_PPN; const router = useRouter(); @@ -225,70 +227,32 @@ const Transaction = ({ id }) => { transaction.refetch(); }; - const countWeight = (products) => { - if (!products || !Array.isArray(products)) return 0.0; - - const weight = products.reduce( - (total, product) => total + (product.weight * product.quantity || 0), - 0 - ); - return weight + ' Kg'; - }; - - // const memoizeVariantGroupCard = useMemo( - // () => ( - // <div className='p-4 pt-0 flex flex-col gap-y-3'> - // <VariantGroupCard variants={transaction.data?.products} buyMore /> - // <div className='font-medium'>Info Pengiriman</div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Metode Pembayaran</p> - // <p> - // {transaction.data?.paymentType - // ? transaction.data?.paymentType - // ?.replace(/_/g, ' ') - // .replace(/\b\w/g, (char) => char.toUpperCase()) - // : '-'} - // </p> - // </div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Berat Barang</p> - // <p>{transaction.data?.pickings[0]?.weightTotal}</p> - // </div> - // <hr className='mt-1 border border-gray-100' /> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Total Belanja</p> - // <p>{currencyFormat(totalAmount)}</p> - // </div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Diskon Belanja</p> - // <p>{'- ' + currencyFormat(totalDiscountAmount)}</p> - // </div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Subtotal</p> - // <p>{currencyFormat(transaction.data?.amountUntaxed)}</p> - // </div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'> - // PPN {((PPN - 1) * 100).toFixed(0)}% - // </p> - // <p>{currencyFormat(transaction.data?.amountTax)}</p> - // </div> - // <div className='flex justify-between mt-1'> - // <p className='text-gray_r-12/70'>Biaya Pengiriman</p> - // <p>{currencyFormat(transaction.data?.deliveryAmount)}</p> - // </div> - // <div className='flex justify-between mt-1 font-medium'> - // <p className='text-gray_r-12/70'>Asuransi Pengiriman</p> - // <p>{currencyFormat(transaction.data?.amountTotal)}</p> - // </div> - // <div className='flex justify-between mt-1 font-medium'> - // <p>Grand Total</p> - // <p>{currencyFormat(transaction.data?.amountTotal)}</p> - // </div> - // </div> - // ), - // [transaction.data] - // ); + const memoizeVariantGroupCard = useMemo( + () => ( + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard variants={transaction.data?.products} buyMore /> + <div className='flex justify-between mt-1'> + <p className='text-gray_r-12/70'>Subtotal</p> + <p>{currencyFormat(transaction.data?.amountUntaxed)}</p> + </div> + <div className='flex justify-between mt-1'> + <p className='text-gray_r-12/70'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </p> + <p>{currencyFormat(transaction.data?.amountTax)}</p> + </div> + <div className='flex justify-between mt-1'> + <p className='text-gray_r-12/70'>Biaya Pengiriman</p> + <p>{currencyFormat(transaction.data?.deliveryAmount)}</p> + </div> + <div className='flex justify-between mt-1 font-medium'> + <p>Grand Total</p> + <p>{currencyFormat(transaction.data?.amountTotal)}</p> + </div> + </div> + ), + [transaction.data] + ); const memoizeVariantGroupCardReject = useMemo( () => ( @@ -595,6 +559,16 @@ const Transaction = ({ id }) => { <Divider /> <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='Status Transaksi'> + <div className='flex justify-end'> + <TransactionStatusBadge status={transaction.data?.status} /> + </div> + </DescriptionRow> + <DescriptionRow label='Status Transaksi'> + <div className='flex justify-end font-semibold text-red-500'> + {transaction.data?.expectedReadyToShip} + </div> + </DescriptionRow> <DescriptionRow label='No Transaksi'> <p className='font-semibold'>{transaction.data?.name}</p> </DescriptionRow> @@ -709,31 +683,7 @@ const Transaction = ({ id }) => { </div> </DescriptionRow> </div> - {/* <div className='flex flex-col gap-y-3 mt-4'> - {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' - 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> - {transaction?.data?.pickings == 0 && ( - <div className='badge-red text-sm px-2'>Belum ada pengiriman</div> - )} */} + </div> {/* <Divider /> @@ -948,48 +898,25 @@ const Transaction = ({ id }) => { )} </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 Transaksi</span> - <div className='text-justify flex flex-col gap-1'> - <p className='font-bold text-black'>Info Transaksi</p> - <span className='text-black'> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna - aliqua. - </span> - </div> - </div> - </div> - <div className='flex flex-row items-center justify-between'> - <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} /> - </div> - <div> - <div className='relative inline-block text-left'> - <button - onClick={() => downloadQuotation(transaction.data)} - type='button' - className='btn-light bg-slate-50 mt-3 w-full gap-2 items-center flex flex-row !text-gray_r-11 px-4 py-3 mb-2' - > - <p>Export to PDF</p> - </button> - </div> - </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} /> </div> {/* <div className='flex gap-x-4'> <button @@ -1284,54 +1211,103 @@ const Transaction = ({ id }) => { </div> </div> - <hr className='mt-4 mb-4 border border-gray-100' /> - - {/* <div className='text-h-sm font-semibold mt-10 mb-4'> - Informasi Pelanggan - </div> - <div className='grid grid-cols-2 gap-x-4'> - <div className='border border-gray_r-6 rounded p-3'> - <div className='font-medium mb-4'>Detail Pelanggan</div> - <SectionContent - address={transaction?.data?.address?.customer} - /> + <div className='flex gap-x-3'> + <div className='w-1/2'> + <div className='text-h-sm font-semibold mt-10 mb-4'> + Informasi Pelanggan + </div> + <div className='border border-gray_r-6 rounded p-3'> + <div className='font-medium mb-4'>Detail Pelanggan</div> + <SectionContent + address={transaction?.data?.address?.customer} + /> + </div> </div> - </div> - <div className='flex '> <div className='w-1/2'> <div className='text-h-sm font-semibold mt-10 mb-4'> - Pengiriman + Informasi Pengiriman </div> {transaction?.data?.pickings.length == 0 && ( <div className='badge-red text-sm'> Belum ada pengiriman </div> )} - <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> - <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> + {/* <div className='grid grid-cols-1 gap-1 w-1/2'> */} + {transaction?.data?.pickings?.map((airway) => ( + <div + key={airway?.id} + className='border border-gray_r-6 rounded p-3' + > + <InformationSection manifests={airway} /> + <div className='p-4'> + <button + className='bg-transparent text-red-600 hover:underline p-0 font-semibold' + onClick={() => { + if (airway?.waybillNumber == '-') { + toast.error('Nomor Resi belum tersedia'); + return; + } + setIdAWB(airway.id); + }} + > + Lacak Pengiriman + </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> */} </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 @@ -1362,7 +1338,7 @@ const Transaction = ({ id }) => { ))} </div> </div> - </div> */} + </div> <div className='text-h-sm font-semibold mt-4 mb-4'> Rincian Pembelian diff --git a/src/lib/treckingAwb/api/getManifest.js b/src/lib/treckingAwb/api/getManifest.js index 7d78a5f2..5523a164 100644 --- a/src/lib/treckingAwb/api/getManifest.js +++ b/src/lib/treckingAwb/api/getManifest.js @@ -1,9 +1,12 @@ -const { default: odooApi } = require("@/core/api/odooApi") -const { getAuth } = require("@/core/utils/auth") +const { default: odooApi } = require('@/core/api/odooApi'); +const { getAuth } = require('@/core/utils/auth'); -export const getManifest = async ({id}) => { - const auth = getAuth() - const manifest = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/stock-picking/${id}/tracking`) +export const getManifest = async ({ id }) => { + const auth = getAuth(); + const manifest = await odooApi( + 'GET', + `/api/v1/partner/${auth.partnerId}/stock-picking/${id}/tracking` + ); - return manifest -}
\ No newline at end of file + return manifest; +}; diff --git a/src/lib/treckingAwb/component/InformationSection.jsx b/src/lib/treckingAwb/component/InformationSection.jsx new file mode 100644 index 00000000..a2297af3 --- /dev/null +++ b/src/lib/treckingAwb/component/InformationSection.jsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +const InformationSection = ({ manifests }) => { + const [copied, setCopied] = useState(false); + + const handleCopyClick = () => { + const textToCopy = manifests?.waybillNumber; + navigator.clipboard.writeText(textToCopy); + setCopied(true); + toast.success('No Resi Berhasil di Copy'); + setTimeout(() => setCopied(false), 2000); // Reset copied state after 2 seconds + }; + + return ( + <div className=''> + <div className='grid gap-y-4 p-4'> + <div className='grid grid-cols-[150px_auto] mb-4'> + <span className='font-semibold '>Nomor Resi</span> + <div className='flex gap-x-3'> + <span className='font-semibold'>: {manifests?.waybillNumber} </span> + <button + className={`${copied ? 'text-gray-400' : 'text-red-600 '}`} + onClick={() => handleCopyClick()} + > + <svg + aria-hidden='true' + fill='none' + stroke='currentColor' + stroke-width='1.5' + viewBox='0 0 24 24' + className='w-5 h-6' + > + <path + d='M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75' + stroke-linecap='round' + stroke-linejoin='round' + ></path> + </svg> + </button> + </div> + </div> + {/*<div className='grid grid-cols-[150px_auto]'>*/} + {/* <span>Barang</span>*/} + {/* <span className='font-semibold'>: {manifests?.products}</span>*/} + {/*</div>*/} + <div className='grid grid-cols-[150px_auto]'> + <span>Kurir</span> + <span className='font-semibold'> + {' '} + : {manifests?.deliveryOrder?.carrier} + </span> + </div> + <div className='grid grid-cols-[150px_auto]'> + <span>Jenis Service</span> + <span className='font-semibold'> + {' '} + : {manifests?.deliveryOrder?.service} + </span> + </div> + <div className='grid grid-cols-[150px_auto]'> + <span>Tanggal Dikirim</span> + <span className='font-semibold'> : {manifests?.deliveredDate}</span> + </div> + <div className='grid grid-cols-[150px_auto]'> + <span>Estimasi Tiba</span> + <span className='font-semibold'> + :{' '} + <span className='text-red-600 font-semibold'>{manifests?.eta}</span> + </span> + </div> + </div> + </div> + ); +}; + +export default InformationSection; diff --git a/src/lib/treckingAwb/component/Manifest.jsx b/src/lib/treckingAwb/component/Manifest.jsx index 109bc832..5b456ccf 100644 --- a/src/lib/treckingAwb/component/Manifest.jsx +++ b/src/lib/treckingAwb/component/Manifest.jsx @@ -6,6 +6,32 @@ import { useEffect, useState } from 'react'; import { toast } from 'react-hot-toast'; import ImageNext from 'next/image'; 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 capitalizeWords(str) { + return str + .split(' ') + .map((word) => capitalizeFirstLetter(word)) + .join(' '); +} + +function mappingLiveTracking(kurir, resi){ + let url = null + switch (kurir){ + case('grab'): + url = 'https://express.grab.com/track/orders?ids='+resi + break; + default: + url = false + } + + return url +} const Manifest = ({ idAWB, closePopup }) => { const [manifests, setManifests] = useState(null); @@ -60,15 +86,7 @@ const Manifest = ({ idAWB, closePopup }) => { } }, [idAWB]); - const [copied, setCopied] = useState(false); - - const handleCopyClick = () => { - const textToCopy = manifests?.waybillNumber; - navigator.clipboard.writeText(textToCopy); - setCopied(true); - toast.success('No Resi Berhasil di Copy'); - setTimeout(() => setCopied(false), 2000); // Reset copied state after 2 seconds - }; + const isLiveTracking = mappingLiveTracking(manifests?.deliveryOrder?.carrier.toLowerCase(), manifests?.waybillNumber) return ( <> @@ -85,7 +103,7 @@ const Manifest = ({ idAWB, closePopup }) => { {!isLoading && ( <BottomPopup key={manifests?.waybillNumber} - title='Detail Pengiriman' + title='Lacak Pengiriman' active={idAWB} close={closePopup} > @@ -101,12 +119,23 @@ const Manifest = ({ idAWB, closePopup }) => { <p className='text-yellow-600 text-sm'>Sedang Dikirim</p> </div> )} + {manifests?.status === 'cancelled' && ( + <div className='bg-red-800 p-2 rounded '> + <p className='text-white text-sm'>Di Batalkan</p> + </div> + )} + {manifests?.status === 'on_hold' && ( + <div className='bg-red-800 p-2 rounded '> + <p className='text-white text-sm'>Ditunda Sementara </p> + </div> + )} {manifests?.status === 'pending' && ( <div className='bg-red-100 p-2 rounded '> <p className='text-red-600 text-sm'>Pending</p> </div> )} </div> +<<<<<<< HEAD <div className=''> <h1 className='text-body-1'> Estimasi tiba pada{' '} @@ -180,25 +209,96 @@ const Manifest = ({ idAWB, closePopup }) => { } `} /> )} +======= +>>>>>>> new-release + + <InformationSection manifests={manifests} /> + + <hr className='mt-4' /> + {manifests?.isDelay && ( + <div + class='flex items-center p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50' + role='alert' + > + <svg + class='flex-shrink-0 inline w-4 h-4 mr-3' + 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>Pesanan anda mungkin mengalami keterlambatan tiba</div> + </div> + )} + <div className='pt-4'> + <ol className='relative'> + {manifests?.manifests?.map((manifest, index) => { + const isFirst = index === 0; + const isDelivered = manifests.delivered === true; + const isBiteship = manifests.isBiteship === true; + const statusTitle = + isDelivered && isFirst && !isBiteship + ? 'Pesanan sampai' + : isBiteship + ? capitalizeWords(manifest.status) + : ''; - <time class='text-sm leading-none text-gray-400'> + return ( + <li + className='grid grid-cols-[80px_20px_1fr] gap-2 mb-6 items-start' + key={index} + > + {/* Kolom 1: Tanggal + Jam */} + <div className='text-sm text-gray-400 whitespace-nowrap'> {formatCustomDate(manifest.datetime)} - </time> - {manifests.delivered == true && index == 0 && ( - <p - class={`leading-6 font-semibold text-sm text-green-600 `} - > - Sudah Sampai + </div> + + {/* Kolom 2: Bullet/Poin */} + <div className='relative flex items-start justify-center pt-1'> + <div + className={`w-3 h-3 rounded-full border ${ + index === 0 && manifests.delivered + ? 'bg-green-600 border-green-600' + : 'bg-gray-400 border-white' + }`} + /> + </div> + + {/* Kolom 3: Status dan Deskripsi */} + <div> + <p className='text-sm font-semibold text-green-600'> + {manifests?.deliveryOrder.carrier != 'Self Pick Up' + ? capitalizeWords(manifest.status) + : ''} </p> - )} - <p class={`leading-6 text-[12px] text-gray_r-11`}> - {manifest.description} - </p> + <p + className='text-xs text-gray-600' + dangerouslySetInnerHTML={{ + __html: manifest.description, + }} + /> + </div> </li> - </> - ))} + ); + })} </ol> </div> + <div className='pt-2'> + { + isLiveTracking && + manifests?.status === 'shipment' && ( + <Link + href={isLiveTracking} + target="_blank" + rel="noopener noreferrer" + className="inline-block px-4 py-2 bg-red-600 text-white font-semibold rounded-lg shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75" + > + Live Tracking + </Link> + ) + } + </div> </BottomPopup> )} </> |
