19.0 vanilla
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#94B6C8"/><stop offset="100%" stop-color="#6A9EBA"/></linearGradient><path id="d" d="M28.278 17.235c5.193 0 9.404 4.271 9.404 9.54 0 5.27-4.21 9.541-9.404 9.541s-9.404-4.271-9.404-9.54c0-5.27 4.21-9.541 9.404-9.541zm6.018 19.492c0-.413-9.178 1.847-12.6-.65l-4.19 1.063c-2.512.638-4.275 2.927-4.275 5.554v2.209c0 1.58 1.264 2.862 2.822 2.862h15.466c-1.852-6.34 2.777-10.625 2.777-11.038zM45.5 33.794c-6.466 0-11.706 5.24-11.706 11.706 0 6.466 5.24 11.706 11.706 11.706 6.466 0 11.706-5.24 11.706-11.706 0-6.466-5.24-11.706-11.706-11.706zm2.695 16.525l-4.163-3.025a.57.57 0 0 1-.231-.458v-7.944c0-.312.255-.566.566-.566h2.266c.311 0 .566.254.566.566v6.5l2.997 2.18a.566.566 0 0 1 .123.793l-1.33 1.831a.57.57 0 0 1-.794.123z"/><path id="e" d="M28.278 15.235c5.193 0 9.404 4.271 9.404 9.54 0 5.27-4.21 9.541-9.404 9.541s-9.404-4.271-9.404-9.54c0-5.27 4.21-9.541 9.404-9.541zm6.018 19.492c0-.413-9.178 1.847-12.6-.65l-4.19 1.063c-2.512.638-4.275 2.927-4.275 5.554v2.209c0 1.58 1.264 2.862 2.822 2.862h15.466c-1.852-6.34 2.777-10.625 2.777-11.038zM45.5 31.794c-6.466 0-11.706 5.24-11.706 11.706 0 6.466 5.24 11.706 11.706 11.706 6.466 0 11.706-5.24 11.706-11.706 0-6.466-5.24-11.706-11.706-11.706zm2.695 16.525l-4.163-3.025a.57.57 0 0 1-.231-.458v-7.944c0-.312.255-.566.566-.566h2.266c.311 0 .566.254.566.566v6.5l2.997 2.18a.566.566 0 0 1 .123.793l-1.33 1.831a.57.57 0 0 1-.794.123z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M42.488 69H4c-2 0-4-1-4-4V41.348l20.938-22.35 14.703 11.46-4.201 5.183-1.252 7.42 6.406-7.111L46 33l9.958 15.844L42.488 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="16" r="10" fill="#FBB945"/><path d="M10.215 9.722c.45-.557.96-1.066 1.518-1.515l7.479 5.999c.985.79 1.053 2.274.16 3.168-.892.893-2.375.825-3.164-.161l-5.993-7.49Z" fill="#fff"/><circle cx="38" cy="24" r="5" fill="#953B24"/><path d="M16 31h26a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4H16V31Z" fill="#953B24"/><path d="M4 31h16c7.18 0 13 5.82 13 13H17C9.82 44 4 38.18 4 31Z" fill="#FBB945"/></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.51001 1080H76.35L1153.55 0H3.51001V1080Z" fill="url(#o_app_switcher_gradient_01)"/>
|
||||
<path d="M76.35 1080H842.98L1920 0.18V0H1153.55L76.35 1080Z" fill="url(#o_app_switcher_gradient_02)"/>
|
||||
<path d="M1920 0.180176L842.98 1080H1063.11L1920 220.88V0.180176Z" fill="url(#o_app_switcher_gradient_03)"/>
|
||||
<path d="M1920 1080V220.88L1063.11 1080H1920Z" fill="url(#o_app_switcher_gradient_04)"/>
|
||||
<rect width="1920" height="1080" fill="url(#o_app_switcher_gradient_05)" fill-opacity="0.25"/>
|
||||
<rect width="1920" height="1080" fill="#E9E6F9" fill-opacity="0.25"/>
|
||||
<defs>
|
||||
<linearGradient id="o_app_switcher_gradient_01" x1="-222.43" y1="727.19" x2="904.26" y2="-76.67" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1" stop-color="white"/>
|
||||
<stop offset="0.36" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.68" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E4E9F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_02" x1="407.23" y1="1021.82" x2="1848.47" y2="-153.08" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.32" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.66" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E5E2F6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_03" x1="1142.33" y1="846.57" x2="1951.83" y2="136.16" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.15" stop-color="white"/>
|
||||
<stop offset="0.51" stop-color="#F7F0FD"/>
|
||||
<stop offset="0.85" stop-color="#F0E7F9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_04" x1="1409.74" y1="1071" x2="2070.98" y2="526.01" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.45" stop-color="white"/>
|
||||
<stop offset="0.88" stop-color="#F7F0FD"/>
|
||||
<stop offset="1" stop-color="#ECE5F8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="o_app_switcher_gradient_05" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(540 960)">
|
||||
<stop stop-color="#9996A9" stop-opacity="0.53"/>
|
||||
<stop offset="1" stop-color="#7A768F"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
|
@ -0,0 +1,56 @@
|
|||
<svg width="232" height="137" viewBox="0 0 232 137" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5H222C226.142 2.5 229.5 5.85786 229.5 10V127C229.5 131.142 226.142 134.5 222 134.5H10C5.85787 134.5 2.5 131.142 2.5 127V10C2.5 5.85786 5.85786 2.5 10 2.5Z" fill="#714B67" fill-opacity="0.75" stroke="#714B67" stroke-width="5"/>
|
||||
<mask id="path-3-inside-1_11_84" fill="white">
|
||||
<rect x="129" width="17.7895" height="26" rx="4" transform="rotate(90 129 0)"/>
|
||||
</mask>
|
||||
<rect x="129" width="17.7895" height="26" rx="4" transform="rotate(90 129 0)" fill="#303030" stroke="#373737" stroke-width="12" mask="url(#path-3-inside-1_11_84)"/>
|
||||
<circle cx="118.053" cy="9.12278" r="2.10526" transform="rotate(90 118.053 9.12278)" fill="#F58787" stroke="#232323" stroke-width="4"/>
|
||||
<circle cx="118.053" cy="9.12282" r="1.36842" transform="rotate(90 118.053 9.12282)" fill="#454545"/>
|
||||
<rect x="110.754" y="7.29822" width="3.64912" height="0.912281" rx="0.45614" transform="rotate(90 110.754 7.29822)" fill="#57E888"/>
|
||||
<rect x="50" y="31" width="132" height="75" rx="7" fill="white"/>
|
||||
<path d="M96.9254 83H96V99H96.9254V83Z" fill="black"/>
|
||||
<path d="M97.8507 83H97.3881V99H97.8507V83Z" fill="black"/>
|
||||
<path d="M100.164 83H98.7761V99H100.164V83Z" fill="black"/>
|
||||
<path d="M102.015 83H101.09V99H102.015V83Z" fill="black"/>
|
||||
<path d="M103.866 83H103.403V99H103.866V83Z" fill="black"/>
|
||||
<path d="M105.254 83H104.791V99H105.254V83Z" fill="black"/>
|
||||
<path d="M107.104 83H106.179V99H107.104V83Z" fill="black"/>
|
||||
<path d="M108.955 83H108.493V99H108.955V83Z" fill="black"/>
|
||||
<path d="M110.343 83H109.881V99H110.343V83Z" fill="black"/>
|
||||
<path d="M112.194 83H111.269V99H112.194V83Z" fill="black"/>
|
||||
<path d="M114.045 83H113.582V99H114.045V83Z" fill="black"/>
|
||||
<path d="M115.433 83H114.97V99H115.433V83Z" fill="black"/>
|
||||
<path d="M117.284 83H116.358V99H117.284V83Z" fill="black"/>
|
||||
<path d="M119.134 83H118.672V99H119.134V83Z" fill="black"/>
|
||||
<path d="M120.522 83H120.06V99H120.522V83Z" fill="black"/>
|
||||
<path d="M122.373 83H121.448V99H122.373V83Z" fill="black"/>
|
||||
<path d="M124.687 83H123.299V99H124.687V83Z" fill="black"/>
|
||||
<path d="M125.612 83H125.149V99H125.612V83Z" fill="black"/>
|
||||
<path d="M127.463 83H126.537V99H127.463V83Z" fill="black"/>
|
||||
<path d="M129.313 83H127.925V99H129.313V83Z" fill="black"/>
|
||||
<path d="M130.701 83H130.239V99H130.701V83Z" fill="black"/>
|
||||
<path d="M132.552 83H131.627V99H132.552V83Z" fill="black"/>
|
||||
<path d="M134.403 83H133.94V99H134.403V83Z" fill="black"/>
|
||||
<path d="M135.791 83H135.328V99H135.791V83Z" fill="black"/>
|
||||
<path d="M137.642 83H136.716V99H137.642V83Z" fill="black"/>
|
||||
<path d="M139.493 83H139.03V99H139.493V83Z" fill="black"/>
|
||||
<path d="M140.881 83H140.418V99H140.881V83Z" fill="black"/>
|
||||
<path d="M142.731 83H141.806V99H142.731V83Z" fill="black"/>
|
||||
<path d="M144.582 83H144.119V99H144.582V83Z" fill="black"/>
|
||||
<path d="M145.97 83H145.507V99H145.97V83Z" fill="black"/>
|
||||
<path d="M148.746 83H146.896V99H148.746V83Z" fill="black"/>
|
||||
<path d="M149.672 83H149.209V99H149.672V83Z" fill="black"/>
|
||||
<path d="M150.597 83H150.134V99H150.597V83Z" fill="black"/>
|
||||
<path d="M152.91 83H151.985V99H152.91V83Z" fill="black"/>
|
||||
<path d="M155.687 83H154.299V99H155.687V83Z" fill="black"/>
|
||||
<path d="M156.612 83H156.149V99H156.612V83Z" fill="black"/>
|
||||
<path d="M158 83H157.075V99H158V83Z" fill="black"/>
|
||||
<path d="M50 38C50 34.134 53.134 31 57 31H88V106H57C53.134 106 50 102.866 50 99V38Z" fill="#714B67"/>
|
||||
<rect x="96" y="41" width="41" height="10" rx="5" fill="#714B67" fill-opacity="0.6"/>
|
||||
<rect x="96" y="60" width="20" height="5" rx="2.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="118" y="60" width="32" height="5" rx="2.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="96" y="69" width="25" height="5" rx="2.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="126" y="69" width="16" height="5" rx="2.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="147" y="69" width="25" height="5" rx="2.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<path d="M69 40C70.4896 40 71.9137 40.2906 73.2723 40.8717C74.631 41.4528 75.8013 42.2344 76.7835 43.2165C77.7656 44.1987 78.5472 45.369 79.1283 46.7277C79.7094 48.0863 80 49.5104 80 51C80 52.4814 79.7115 53.9014 79.1345 55.26C78.5575 56.6187 77.7779 57.7891 76.7958 58.7712C75.8136 59.7533 74.6432 60.537 73.2846 61.1222C71.926 61.7074 70.4978 62 69 62C67.5022 62 66.074 61.7094 64.7154 61.1283C63.3568 60.5472 62.1884 59.7636 61.2104 58.7773C60.2323 57.7911 59.4528 56.6207 58.8717 55.2662C58.2906 53.9116 58 52.4896 58 51C58 49.5104 58.2906 48.0863 58.8717 46.7277C59.4528 45.369 60.2344 44.1987 61.2165 43.2165C62.1987 42.2344 63.369 41.4528 64.7277 40.8717C66.0863 40.2906 67.5104 40 69 40ZM76.5993 56.5859C77.8188 54.9081 78.4286 53.0461 78.4286 51C78.4286 49.7232 78.1789 48.5037 77.6797 47.3415C77.1804 46.1793 76.5093 45.1767 75.6663 44.3337C74.8233 43.4907 73.8207 42.8196 72.6585 42.3203C71.4963 41.8211 70.2768 41.5714 69 41.5714C67.7232 41.5714 66.5037 41.8211 65.3415 42.3203C64.1793 42.8196 63.1767 43.4907 62.3337 44.3337C61.4907 45.1767 60.8196 46.1793 60.3203 47.3415C59.8211 48.5037 59.5714 49.7232 59.5714 51C59.5714 53.0461 60.1812 54.9081 61.4007 56.5859C61.9408 53.9096 63.1931 52.5714 65.1574 52.5714C66.2295 53.619 67.5104 54.1429 69 54.1429C70.4896 54.1429 71.7705 53.619 72.8426 52.5714C74.8069 52.5714 76.0592 53.9096 76.5993 56.5859ZM73.7143 48.6429C73.7143 47.3415 73.2539 46.2305 72.3331 45.3097C71.4124 44.389 70.3013 43.9286 69 43.9286C67.6987 43.9286 66.5876 44.389 65.6669 45.3097C64.7461 46.2305 64.2857 47.3415 64.2857 48.6429C64.2857 49.9442 64.7461 51.0552 65.6669 51.976C66.5876 52.8968 67.6987 53.3571 69 53.3571C70.3013 53.3571 71.4124 52.8968 72.3331 51.976C73.2539 51.0552 73.7143 49.9442 73.7143 48.6429Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
|
@ -0,0 +1,38 @@
|
|||
<svg width="232" height="137" viewBox="0 0 232 137" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5H222C226.142 2.5 229.5 5.85786 229.5 10V127C229.5 131.142 226.142 134.5 222 134.5H10C5.85787 134.5 2.5 131.142 2.5 127V10C2.5 5.85786 5.85786 2.5 10 2.5Z" fill="#714B67" fill-opacity="0.75" stroke="#714B67" stroke-width="5"/>
|
||||
<g clip-path="url(#clip0_11_116)">
|
||||
<rect x="14" y="51" width="204" height="35" rx="4" fill="white"/>
|
||||
<rect x="24" y="55.5" width="26" height="26" rx="4" fill="#D9CFD7"/>
|
||||
<rect x="60" y="58.5" width="43" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="107" y="58.5" width="88" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="60" y="71.5" width="52" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="122" y="71.5" width="35" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_11_116)">
|
||||
<rect x="14" y="90" width="204" height="35" rx="4" fill="white"/>
|
||||
<rect x="24" y="94.5" width="26" height="26" rx="4" fill="#714B67" fill-opacity="0.75"/>
|
||||
<rect x="60" y="97.5" width="43" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="107" y="97.5" width="88" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="60" y="110.5" width="52" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="122" y="110.5" width="35" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_11_116)">
|
||||
<rect x="14" y="12" width="204" height="35" rx="4" fill="white"/>
|
||||
<rect x="24" y="16.5" width="26" height="26" rx="4" fill="#714B67" fill-opacity="0.6"/>
|
||||
<rect x="60" y="19.5" width="43" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="107" y="19.5" width="88" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="60" y="32.5" width="52" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
<rect x="122" y="32.5" width="35" height="7" rx="3.5" fill="#714B67" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_11_116">
|
||||
<rect x="14" y="51" width="204" height="35" rx="4" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_11_116">
|
||||
<rect x="14" y="90" width="204" height="35" rx="4" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_11_116">
|
||||
<rect x="14" y="12" width="204" height="35" rx="4" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg width="232" height="137" viewBox="0 0 232 137" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5H222C226.142 2.5 229.5 5.85786 229.5 10V127C229.5 131.142 226.142 134.5 222 134.5H10C5.85787 134.5 2.5 131.142 2.5 127V10C2.5 5.85786 5.85786 2.5 10 2.5Z" fill="#714B67" fill-opacity="0.75" stroke="#714B67" stroke-width="5"/>
|
||||
<path d="M98.7909 104.627V110.134H94V95H99.8098C104.629 95 107.039 96.4907 107.039 99.4717C107.039 101.225 106.036 102.581 104.031 103.54L109.198 110.134H103.764L100.004 104.627H98.7909ZM98.7909 101.552H99.6885C101.362 101.552 102.199 100.921 102.199 99.6582C102.199 98.6159 101.378 98.0951 99.737 98.0951H98.7909V101.552Z" fill="white"/>
|
||||
<path d="M115.529 110.134H110.811V95H121.29V98.2813H115.529V101.169H120.841V104.451H115.529V110.134Z" fill="white"/>
|
||||
<path d="M124.201 110.134V95H129.016V110.134H124.201Z" fill="white"/>
|
||||
<path d="M147.913 102.246C147.913 104.772 147.096 106.718 145.463 108.084C143.838 109.45 141.549 110.134 138.598 110.134H132.861V95H138.998C141.844 95 144.04 95.6211 145.585 96.8631C147.137 98.1054 147.913 99.8996 147.913 102.246ZM142.94 102.391C142.94 101.004 142.617 99.9755 141.97 99.306C141.331 98.6368 140.357 98.3022 139.047 98.3022H137.652V106.78H138.719C140.175 106.78 141.242 106.421 141.921 105.703C142.6 104.979 142.94 103.874 142.94 102.391Z" fill="white"/>
|
||||
<g clip-path="url(#clip0_11_36)">
|
||||
<path d="M85.0402 66.9514C84.9849 66.3632 84.9112 65.8914 84.8989 65.4134C84.8313 62.4723 84.8497 59.5312 84.7145 56.5962C84.5363 52.7544 86.3122 50.2912 89.7227 48.6675C93.6616 46.7864 97.5514 44.795 101.361 42.6688C104.034 41.1738 106.517 39.3478 109.116 37.7241C113.08 35.2548 117.338 33.5146 122.033 33.0857C129.044 32.4423 135.404 34.2744 140.972 38.5451C147.08 43.2264 150.736 49.4518 151.424 57.0987C152.414 68.1278 148.1 76.7919 138.821 82.8886C134.642 85.6275 129.941 86.902 124.946 87C121.677 87.0613 118.586 86.1728 115.544 85.0576C111.722 83.6545 108.219 81.6202 104.704 79.6043C100.095 76.9634 95.3638 74.5799 90.3433 72.803C90.0054 72.6804 89.6797 72.5028 89.3294 72.3373C90.3863 71.3692 91.388 70.4378 92.408 69.531C92.5125 69.433 92.7214 69.4268 92.8873 69.4268C93.9443 69.4207 95.0012 69.4452 96.0581 69.4207C97.6435 69.3839 98.639 68.3791 98.6144 66.8105C98.553 62.4968 98.4731 58.1832 98.3809 53.8757C98.3441 52.1049 97.8279 51.6208 96.052 51.5902C95.118 51.5779 94.1839 51.5841 93.2499 51.6331C91.517 51.725 90.6014 52.7054 90.626 54.4272C90.6506 56.1428 90.7305 57.8585 90.7182 59.5741C90.7182 60.0582 90.5891 60.5974 90.3556 61.0201C89.2004 63.1647 87.6272 64.9661 85.7223 66.4796C85.538 66.6266 85.3352 66.7492 85.0341 66.9575L85.0402 66.9514ZM125.437 43.4776C116.263 43.4592 108.895 50.763 108.852 59.9356C108.809 68.9857 116.244 76.4304 125.333 76.4426C134.446 76.4549 141.906 69.0837 141.93 60.0337C141.961 50.8978 134.581 43.4899 125.431 43.4715L125.437 43.4776Z" fill="white"/>
|
||||
<path d="M170.486 83.1704C169.484 83.0724 168.987 82.7538 168.747 82.1165C168.489 81.4241 168.661 80.8236 169.177 80.3028C171.014 78.4401 172.557 76.363 173.866 74.102C180.539 62.5458 178.407 47.4604 168.802 38.1714C168.409 37.7915 168.089 37.2216 167.991 36.6947C167.874 36.0574 168.28 35.5121 168.901 35.2364C169.57 34.9423 170.246 35.0158 170.726 35.5734C172.323 37.4238 174.068 39.1885 175.433 41.1983C184.109 53.9798 182.487 71.3508 171.666 82.38C171.285 82.7721 170.738 83.005 170.486 83.1704Z" fill="white"/>
|
||||
<path d="M173.073 60.2358C172.987 66.4857 170.523 72.5701 165.631 77.6803C165.207 78.1215 164.753 78.4585 164.095 78.3972C163.444 78.3359 162.989 78.0112 162.737 77.423C162.448 76.7367 162.608 76.124 163.124 75.6031C164.71 74.01 166.031 72.2209 167.1 70.2479C171.795 61.5838 170.302 50.714 163.438 43.6246C163.216 43.3979 162.983 43.1774 162.762 42.9445C162.043 42.1909 162.018 41.2534 162.688 40.5855C163.321 39.9605 164.359 39.9115 164.999 40.6284C166.344 42.148 167.751 43.643 168.87 45.328C171.703 49.5804 173.042 54.3168 173.067 60.2358L173.073 60.2358Z" fill="white"/>
|
||||
<path d="M156.193 71.743C156.494 71.2957 156.746 70.7994 157.108 70.4011C160.967 66.2223 162.362 61.3694 161.115 55.8181C160.482 52.9811 159.062 50.5486 157.022 48.4653C156.721 48.1589 156.432 47.7852 156.303 47.3869C156.07 46.6639 156.432 45.9408 157.084 45.5793C157.686 45.2423 158.466 45.3587 159.044 45.9163C161.846 48.6124 163.696 51.866 164.421 55.671C165.631 62.0618 164.009 67.6928 159.579 72.4967C159.382 72.7111 159.173 72.9317 158.94 73.1033C158.405 73.5138 157.803 73.5689 157.207 73.2626C156.641 72.9685 156.34 72.4844 156.193 71.7369L156.193 71.743Z" fill="white"/>
|
||||
<path d="M139.067 59.9601C139.091 67.4354 132.959 73.5689 125.431 73.5995C117.903 73.624 111.746 67.5457 111.709 60.0582C111.672 52.4174 117.75 46.3513 125.443 46.3329C132.934 46.3146 139.036 52.4235 139.067 59.9662L139.067 59.9601Z" fill="white"/>
|
||||
<path d="M72.7606 76.4487C85.751 76.4487 96.1231 67.6182 96.756 54.8182C96.756 54.8182 96.2129 53.2091 95.1463 53.2091C94.0797 53.2091 93.5365 54.8182 93.5365 54.8182C92.9097 65.5778 83.7048 72.7478 72.7606 72.7478C61.8164 72.7478 52.1749 63.5385 52.1749 52.2213C52.1749 40.9041 61.4108 31.6948 72.7606 31.6948C81.6769 31.6948 89.0694 36.1683 91.9268 44.0909C91.9268 44.0909 92.9999 45.1637 94.0731 44.6273C95.1463 44.0909 94.6097 42.4818 94.6097 42.4818C91.1378 33.3337 83.1394 27.9939 72.7606 27.9939C59.3646 27.9939 48.4634 38.8637 48.4634 52.2213C48.4634 65.5789 59.3646 76.4487 72.7606 76.4487Z" fill="white"/>
|
||||
</g>
|
||||
<rect x="202.316" y="47" width="27.4737" height="42" rx="2" fill="#303030" stroke="#373737" stroke-width="4"/>
|
||||
<circle cx="216.154" cy="64.3081" r="8.15891" fill="#232323"/>
|
||||
<path d="M217.764 67.697C217.757 67.697 217.679 67.6487 217.532 67.552C217.385 67.4553 217.189 67.3586 216.943 67.2619C216.698 67.1652 216.449 67.1169 216.198 67.1169C215.947 67.1169 215.698 67.1652 215.453 67.2619C215.207 67.3586 215.012 67.4553 214.867 67.552C214.722 67.6487 214.643 67.697 214.632 67.697C214.562 67.697 214.381 67.552 214.089 67.2619C213.797 66.9718 213.651 66.792 213.651 66.7224C213.651 66.6721 213.671 66.6276 213.709 66.5889C214.011 66.2911 214.39 66.0572 214.846 65.887C215.303 65.7168 215.753 65.6317 216.198 65.6317C216.643 65.6317 217.093 65.7168 217.55 65.887C218.006 66.0572 218.385 66.2911 218.687 66.5889C218.725 66.6276 218.745 66.6721 218.745 66.7224C218.745 66.792 218.599 66.9718 218.307 67.2619C218.015 67.552 217.834 67.697 217.764 67.697ZM219.348 66.119C219.306 66.119 219.261 66.1036 219.215 66.0726C218.689 65.6665 218.201 65.3678 217.753 65.1763C217.304 64.9849 216.786 64.8892 216.198 64.8892C215.869 64.8892 215.54 64.9317 215.209 65.0168C214.878 65.1019 214.59 65.2044 214.344 65.3243C214.099 65.4442 213.879 65.5641 213.686 65.6839C213.493 65.8038 213.34 65.9063 213.228 65.9914C213.116 66.0765 213.056 66.119 213.048 66.119C212.982 66.119 212.804 65.974 212.514 65.6839C212.224 65.3939 212.079 65.214 212.079 65.1444C212.079 65.098 212.098 65.0555 212.137 65.0168C212.648 64.5063 213.266 64.1099 213.993 63.8275C214.721 63.5452 215.455 63.404 216.198 63.404C216.941 63.404 217.675 63.5452 218.402 63.8275C219.13 64.1099 219.748 64.5063 220.259 65.0168C220.298 65.0555 220.317 65.098 220.317 65.1444C220.317 65.214 220.172 65.3939 219.882 65.6839C219.592 65.974 219.414 66.119 219.348 66.119ZM220.92 64.5469C220.878 64.5469 220.835 64.5295 220.793 64.4947C220.1 63.8875 219.382 63.4301 218.637 63.1227C217.893 62.8152 217.08 62.6615 216.198 62.6615C215.316 62.6615 214.503 62.8152 213.759 63.1227C213.014 63.4301 212.296 63.8875 211.603 64.4947C211.561 64.5295 211.518 64.5469 211.476 64.5469C211.41 64.5469 211.231 64.4019 210.939 64.1118C210.647 63.8217 210.501 63.6419 210.501 63.5723C210.501 63.522 210.52 63.4775 210.559 63.4388C211.282 62.7195 212.143 62.1626 213.141 61.7681C214.139 61.3736 215.158 61.1763 216.198 61.1763C217.238 61.1763 218.257 61.3736 219.255 61.7681C220.253 62.1626 221.114 62.7195 221.837 63.4388C221.876 63.4775 221.895 63.522 221.895 63.5723C221.895 63.6419 221.749 63.8217 221.457 64.1118C221.165 64.4019 220.986 64.5469 220.92 64.5469Z" fill="white"/>
|
||||
<rect x="213.228" y="78.2263" width="6.45614" height="1.61404" rx="0.807018" fill="#57E888"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_11_36">
|
||||
<rect width="132" height="59" fill="white" transform="translate(181 87) rotate(-180)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
|
@ -0,0 +1,137 @@
|
|||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
import { rpc, ConnectionLostError } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { isIosApp } from "@web/core/browser/feature_detection";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class ActivityMenu extends Component {
|
||||
static components = {Dropdown, DropdownItem};
|
||||
static props = [];
|
||||
static template = "hr_attendance.attendance_menu";
|
||||
|
||||
setup() {
|
||||
this.ui = useService("ui");
|
||||
this.lazySession = useService("lazy_session");
|
||||
this.notification = useService("notification");
|
||||
this.dialogService = useService("dialog");
|
||||
this.employee = false;
|
||||
this.state = useState({
|
||||
checkedIn: false,
|
||||
isDisplayed: false
|
||||
});
|
||||
this.date_formatter = registry.category("formatters").get("float_time")
|
||||
this.dropdown = useDropdownState();
|
||||
onWillStart(()=> {
|
||||
// access lazy session but do no wait for it, to prevent from delaying the whole webclient
|
||||
this.lazySession.getValue("attendance_user_data", (employee) => {
|
||||
if (employee) {
|
||||
this.employee = employee;
|
||||
this._searchReadEmployeeFill();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async searchReadEmployee(){
|
||||
this.employee = await rpc("/hr_attendance/attendance_user_data");
|
||||
this._searchReadEmployeeFill();
|
||||
}
|
||||
|
||||
_searchReadEmployeeFill() {
|
||||
if (this.employee.id) {
|
||||
this.hoursToday = this.date_formatter(
|
||||
this.employee.hours_today
|
||||
);
|
||||
this.hoursPreviouslyToday = this.date_formatter(
|
||||
this.employee.hours_previously_today
|
||||
);
|
||||
this.lastAttendanceWorkedHours = this.date_formatter(
|
||||
this.employee.last_attendance_worked_hours
|
||||
);
|
||||
this.lastCheckIn = deserializeDateTime(this.employee.last_check_in).toLocaleString(DateTime.TIME_SIMPLE);
|
||||
this.state.checkedIn = this.employee.attendance_state === "checked_in";
|
||||
this.isFirstAttendance = this.employee.hours_previously_today === 0;
|
||||
this.state.isDisplayed = this.employee.display_systray
|
||||
} else {
|
||||
this.state.isDisplayed = false
|
||||
}
|
||||
}
|
||||
|
||||
async checking(latitude = false, longitude = false) {
|
||||
try {
|
||||
this.employee = await rpc("/hr_attendance/systray_check_in_out", {
|
||||
latitude,
|
||||
longitude
|
||||
})
|
||||
this._searchReadEmployeeFill();
|
||||
} catch (error) {
|
||||
if(error instanceof ConnectionLostError) {
|
||||
this.notification.add(
|
||||
_t("Connection lost. Check in/out could not be recorded."),
|
||||
{
|
||||
title: _t("Attendance Error"),
|
||||
type: "danger",
|
||||
sticky: false,
|
||||
}
|
||||
);
|
||||
}else{
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this._attendanceInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
confirmChecking() {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
body: _t("Unable to get a valid location. Do you want to proceed with your check-in/out anyway?"),
|
||||
confirmLabel: _t("Proceed Anyway"),
|
||||
confirm: async () => await this.checking(),
|
||||
cancel: () => this._attendanceInProgress = false,
|
||||
});
|
||||
}
|
||||
|
||||
async signInOut() {
|
||||
this.dropdown.close();
|
||||
if (this._attendanceInProgress) {
|
||||
return;
|
||||
}
|
||||
this._attendanceInProgress = true;
|
||||
|
||||
const trackingEnabled = this.employee && this.employee.device_tracking_enabled;
|
||||
if (trackingEnabled && !isIosApp() && navigator.geolocation && navigator.onLine) {
|
||||
// iOS app lacks permissions to call `getCurrentPosition`
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async ({coords: {latitude, longitude}}) => {
|
||||
await this.checking(latitude,longitude);
|
||||
},
|
||||
() => {
|
||||
this.confirmChecking();
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
} else if (trackingEnabled) {
|
||||
this.confirmChecking();
|
||||
} else {
|
||||
await this.checking();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const systrayAttendance = {
|
||||
Component: ActivityMenu,
|
||||
};
|
||||
|
||||
registry
|
||||
.category("systray")
|
||||
.add("hr_attendance.attendance_menu", systrayAttendance, { sequence: 101 });
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.att_container {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.attendance_menu">
|
||||
<t t-if="this.state.isDisplayed">
|
||||
<Dropdown position="'bottom-end'" beforeOpen.bind="searchReadEmployee" menuClass="`p-2 pb-3`" state="dropdown">
|
||||
<button>
|
||||
<i class="fa fa-circle" t-attf-class="text-{{ this.state.checkedIn ? 'success' : 'danger' }}" role="img" aria-label="Attendance"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="o_att_menu_container d-flex flex-column gap-4">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div t-if="this.state.checkedIn" class="d-flex flex-wrap gap-3">
|
||||
<div t-if="!this.isFirstAttendance" class="att_container flex-grow-1 flex-shrink-0">
|
||||
<small class="d-block text-muted">Before <t t-esc="this.lastCheckIn"/></small>
|
||||
<div t-esc="this.hoursPreviouslyToday" class="fs-3 text-info text-end"/>
|
||||
</div>
|
||||
<div class="att_container flex-grow-1 flex-shrink-0">
|
||||
<small class="d-block text-muted">Since <t t-esc="this.lastCheckIn"/></small>
|
||||
<div t-esc="this.lastAttendanceWorkedHours" t-attf-class="fs-3 text-info {{ !this.isFirstAttendance ? 'text-end' : '' }}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!this.isFirstAttendance"
|
||||
class="att_container d-flex flex-column"
|
||||
t-att-class="this.state.checkedIn ? 'p-3 bg-100 rounded' : ''">
|
||||
<div class="d-flex" t-att-class="this.state.checkedIn ? 'align-items-center justify-content-between' : 'flex-column'">
|
||||
<small class="text-muted">Total today</small>
|
||||
<h2 t-esc="this.hoursToday" class="mb-0 fs-2"/>
|
||||
</div>
|
||||
<button t-on-click="() => this.signInOut()" class="flex-basis-100 mt-3" t-attf-class="btn btn-{{ this.state.checkedIn ? 'warning' : 'success' }}">
|
||||
<span t-if="!this.state.checkedIn">Check in</span>
|
||||
<span t-else="">Check out</span>
|
||||
<i t-attf-class="fa fa-sign-{{ this.state.checkedIn ? 'out' : 'in' }} ms-1"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button t-if="this.isFirstAttendance" t-on-click="() => this.signInOut()" t-attf-class="btn btn-{{ this.state.checkedIn ? 'warning' : 'success' }}">
|
||||
<span t-if="!this.state.checkedIn">Check in</span>
|
||||
<span t-else="">Check out</span>
|
||||
<i t-attf-class="fa fa-sign-{{ this.state.checkedIn ? 'out' : 'in' }} ms-1"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Component, useState, onWillUnmount } from "@odoo/owl";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
export class CardLayout extends Component {
|
||||
static template = "hr_attendance.CardLayout";
|
||||
static props = {
|
||||
slots: Object,
|
||||
fromTrialMode: { type: Boolean, optional: true },
|
||||
companyImageUrl: { type: String },
|
||||
kioskReturn: { type: Function },
|
||||
activeDisplay: { type: String },
|
||||
};
|
||||
static defaultProps = {
|
||||
kioskModeClasses: "",
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState(this.getDateTime());
|
||||
this.timeInterval = setInterval(() => {
|
||||
Object.assign(this.state, this.getDateTime());
|
||||
}, 1000);
|
||||
onWillUnmount(() => {
|
||||
clearInterval(this.timeInterval);
|
||||
});
|
||||
}
|
||||
|
||||
getDateTime() {
|
||||
const now = DateTime.now();
|
||||
return {
|
||||
dayOfWeek: now.toFormat("cccc"),
|
||||
date: now.toLocaleString({
|
||||
...DateTime.DATE_FULL,
|
||||
weekday: undefined,
|
||||
}),
|
||||
time: now.toLocaleString(DateTime.TIME_SIMPLE),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.CardLayout">
|
||||
<div class="o_attendance_background d-flex flex-column h-100">
|
||||
<t t-if="env.isSmall">
|
||||
<div class="p-2 d-flex justify-content-between">
|
||||
<button
|
||||
t-on-click="props.kioskReturn"
|
||||
class="o_hr_attendance_back_button btn btn-secondary rounded-pill"
|
||||
t-if="this.props.fromTrialMode">
|
||||
<i class="oi oi-chevron-left" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
<t t-call="hr_attendance.companyHeader" t-if="this.props.activeDisplay != 'settings'">
|
||||
<t t-set="companyImageUrl" t-value="this.props.companyImageUrl"/>
|
||||
<t t-set="companyName" t-value="this.props.companyName"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_top d-flex flex-column-reverse flex-sm-row justify-content-between mx-4 my-3" t-if="this.props.activeDisplay != 'settings'">
|
||||
<div class="o_hr_kiosk_mode_top_time d-flex flex-column-reverse flex-sm-row align-items-center justify-content-center mt-2 mt-sm-0">
|
||||
<span class="me-0 me-sm-2 display-6" t-esc="state.time"/>
|
||||
<div class="d-flex flex-sm-column gap-1 gap-sm-0 small">
|
||||
<span t-esc="state.dayOfWeek"/>
|
||||
<span t-esc="state.date"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="p-2 d-flex justify-content-between">
|
||||
<div class="m-2">
|
||||
<button
|
||||
t-on-click="props.kioskReturn"
|
||||
class="o_hr_attendance_back_button btn btn-secondary rounded-pill"
|
||||
t-if="this.props.fromTrialMode">
|
||||
<i class="oi oi-chevron-left" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_top d-flex flex-column-reverse flex-sm-row justify-content-between mx-4 my-3" t-if="this.props.activeDisplay != 'settings'">
|
||||
<div class="o_hr_kiosk_mode_top_time d-flex flex-column-reverse flex-sm-row align-items-center justify-content-center mt-2 mt-sm-0">
|
||||
<span class="me-0 me-sm-2 display-6" t-esc="state.time"/>
|
||||
<div class="d-flex flex-sm-column gap-1 gap-sm-0 small">
|
||||
<span t-esc="state.dayOfWeek"/>
|
||||
<span t-esc="state.date"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-call="hr_attendance.companyHeader" t-if="this.props.activeDisplay != 'settings'">
|
||||
<t t-set="companyImageUrl" t-value="this.props.companyImageUrl"/>
|
||||
<t t-set="companyName" t-value="this.props.companyName"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<div t-attf-class="o_hr_attendance_kiosk_mode d-flex flex-column w-100 h-100 {{props.kioskModeClasses}} overflow-auto px-3">
|
||||
<t t-slot="default" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
|
||||
export class CheckInOut extends Component {
|
||||
static template = "hr_attendance.CheckInOut";
|
||||
static props = {
|
||||
checkedIn: Boolean,
|
||||
employeeId: Number,
|
||||
nextAction: String,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.onClickSignInOut = useDebounced(this.signInOut, 200, { immediate: true });
|
||||
}
|
||||
|
||||
async signInOut() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({coords: {latitude, longitude}}) => {
|
||||
this.orm.call("hr.employee", "update_last_position", [
|
||||
[this.props.employeeId],
|
||||
latitude,
|
||||
longitude
|
||||
])
|
||||
},
|
||||
err => {
|
||||
this.orm.call("hr.employee", "update_last_position", [
|
||||
[this.props.employeeId],
|
||||
false,
|
||||
false
|
||||
])
|
||||
})
|
||||
const result = await this.orm.call("hr.employee", "attendance_manual", [
|
||||
[this.props.employeeId],
|
||||
this.props.nextAction,
|
||||
]);
|
||||
if (result.action) {
|
||||
this.actionService.doAction(result.action);
|
||||
} else if (result.warning) {
|
||||
this.notification.add(result.warning, {type: "danger"});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.CheckInOut">
|
||||
<div class="flex-grow-1">
|
||||
<button t-on-click="() => this.onClickSignInOut()" t-attf-class="o_hr_attendance_sign_in_out_icon btn btn-{{ props.checkedIn ? 'warning' : 'success' }} align-self-center px-5 py-3 mt-4 mb-2">
|
||||
<span class="align-middle fs-2 me-3 text-white" t-if="!props.checkedIn">Check IN</span>
|
||||
<i t-attf-class="fa fa-4x fa-sign-{{ props.checkedIn ? 'out' : 'in' }} align-middle"/>
|
||||
<span class="align-middle fs-2 ms-3" t-if="props.checkedIn">Check OUT</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import {Component, onWillDestroy} from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
export class KioskGreetings extends Component {
|
||||
static template = "hr_attendance.public_kiosk_greetings";
|
||||
static props = {
|
||||
employeeData: { type: Object },
|
||||
kioskReturn: { type: Function },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.formatDateTime = registry.category("formatters").get("datetime");
|
||||
this.formatFloatTime = registry.category("formatters").get("float_time");
|
||||
this.employeeName = this.props.employeeData.employee_name;
|
||||
this.employeeAvatar = this.props.employeeData.employee_avatar;
|
||||
this.hoursToday = this.formatFloatTime(this.props.employeeData.hours_today);
|
||||
this.attendance = this.props.employeeData.attendance;
|
||||
this.check_in_time = this.formatDateTime(this.attendance.check_in && deserializeDateTime(this.attendance.check_in));
|
||||
this.check_out_time = this.formatDateTime(this.attendance.check_out && deserializeDateTime(this.attendance.check_out));
|
||||
this.kiosk_delay = setTimeout(() => {
|
||||
this.props.kioskReturn(true)
|
||||
}, this.props.employeeData.kiosk_delay)
|
||||
if (this.props.employeeData.display_overtime){
|
||||
this.overtimeToday = this.formatFloatTime(this.props.employeeData.overtime_today);
|
||||
this.totalOvertime = this.formatFloatTime(this.props.employeeData.total_overtime);
|
||||
}
|
||||
onWillDestroy(() => clearTimeout(this.kiosk_delay));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.EmployeeBadge">
|
||||
<div class="o_hr_attendance_user_badge text-center">
|
||||
<img
|
||||
class="o_hr_attendance_employee_badge img rounded-circle"
|
||||
t-attf-src="{{employeeAvatar}}"
|
||||
t-attf-height="{{ employeeAvatarHeight or '120'}}"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.public_kiosk_greetings">
|
||||
<t t-if="this.attendance">
|
||||
<div class="o_hr_kiosk_mode_main d-flex flex-grow-1 justify-content-center align-items-center">
|
||||
<div class="o_hr_attendance_kiosk_card card rounded-3">
|
||||
<div class="card-body rounded-3">
|
||||
<div class="o_hr_attendance_kiosk_card_wrapper d-flex flex-column align-items-center justify-content-center">
|
||||
<div class="o_hr_attendance_kiosk_card_main p-3 mx-3 mx-xl-0 text-center">
|
||||
<t t-call="hr_attendance.EmployeeBadge">
|
||||
<t t-set="employeeAvatar" t-value="this.employeeAvatar"/>
|
||||
</t>
|
||||
<h5 class="text-muted mt-2 mb-0">
|
||||
<t t-if="attendance.check_out" >Goodbye</t>
|
||||
<t t-else="">Welcome</t>
|
||||
</h5>
|
||||
<h2><t t-esc="this.employeeName"/></h2>
|
||||
</div>
|
||||
<div class="o_hr_attendance_kiosk_card_bottom d-flex flex-column w-100">
|
||||
<div class="alert alert-info text-center p-3" role="status">
|
||||
<t t-if="attendance.check_out">
|
||||
Checked out at <strong><t t-esc="this.check_out_time"/></strong>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Checked in at <strong><t t-esc="this.check_in_time"/></strong>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex flex-column w-100">
|
||||
<div
|
||||
class="alert alert-info text-center p-3"
|
||||
t-if="this.hoursToday != '00:00'"
|
||||
role="status">
|
||||
<t t-if="attendance.check_out">
|
||||
Hours Today: <strong><t t-esc="this.hoursToday"/></strong>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Hours Previously Today: <strong><t t-esc="this.hoursToday"/></strong>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="attendance.check_out and this.overtimeToday" class="alert alert-warning d-flex flex-column gap-2 mb-0 p-2 p-xl-3 border border-warning text-center">
|
||||
<div class="small fw-bolder text-uppercase">
|
||||
Extra hours
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="d-flex flex-column w-100 mb-0 ">
|
||||
<span class="text-nowrap">Today</span>
|
||||
<span class="fs-6"><strong><t t-esc="this.overtimeToday"/></strong></span>
|
||||
</div>
|
||||
<div t-if="this.totalOvertime" class="d-flex flex-column w-100 mb-0 ">
|
||||
<span class="text-nowrap">Total</span>
|
||||
<span class="fs-6"><strong><t t-esc="this.totalOvertime"/></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_bottom my-4 mx-auto">
|
||||
<button class="btn btn-primary btn-lg rounded-pill px-5" t-on-click="this.props.kioskReturn">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_hr_kiosk_mode_main d-flex flex-grow-1 justify-content-center align-items-center">
|
||||
<div class="alert alert-warning px-5 text-center" role="alert">
|
||||
<h4 class="alert-heading mt-3">Invalid request</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_bottom my-4 mx-auto">
|
||||
<button class="btn btn-primary btn-lg rounded-pill px-5" t-on-click="this.props.kioskReturn">
|
||||
<i class="oi oi-chevron-left me-2"/>
|
||||
<span>Go back</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
|
||||
import { scanBarcode } from "@web/core/barcode/barcode_dialog";
|
||||
import { isDisplayStandalone } from "@web/core/browser/feature_detection";
|
||||
|
||||
export class KioskBarcodeScanner extends BarcodeScanner {
|
||||
static props = {
|
||||
...BarcodeScanner.props,
|
||||
barcodeSource: String,
|
||||
token: String,
|
||||
kioskMode: String,
|
||||
fromTrialMode: Boolean,
|
||||
};
|
||||
static template = "hr_attendance.BarcodeScanner";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.isDisplayStandalone = isDisplayStandalone();
|
||||
this.scanBarcode = () => scanBarcode(this.env, this.facingMode);
|
||||
}
|
||||
|
||||
get facingMode() {
|
||||
if (this.props.barcodeSource == "front") {
|
||||
return "user";
|
||||
}
|
||||
return super.facingMode;
|
||||
}
|
||||
|
||||
get installURL() {
|
||||
const url = `hr_attendance/${this.props.token}`;
|
||||
return `/scoped_app?app_id=hr_attendance&path=${encodeURIComponent(url)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { Pager } from "@web/core/pager/pager";
|
||||
import { MEDIAS_BREAKPOINTS, SIZES } from "@web/core/ui/ui_service";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class KioskManualSelection extends Component {
|
||||
static template = "hr_attendance.public_kiosk_manual_selection";
|
||||
static components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
Pager,
|
||||
};
|
||||
static props = {
|
||||
displayBackButton: { type: Boolean },
|
||||
token: { type: String },
|
||||
departments: { type: Array },
|
||||
onSelectEmployee: { type: Function },
|
||||
onClickBack: { type: Function },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
let limit = this.calculateLimit();
|
||||
this.state = useState({
|
||||
employeesData: {
|
||||
count: 0,
|
||||
records: [],
|
||||
},
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
searchInput: "",
|
||||
searchDomain: [],
|
||||
departmentDomain: [],
|
||||
});
|
||||
this.departmentName = _t("All departments");
|
||||
onWillStart(async () => {
|
||||
await this._fetchEmployeeData();
|
||||
})
|
||||
}
|
||||
|
||||
calculateLimit() {
|
||||
// This function calculates the maximum number of employee cards that can fit on the screen based on his size,
|
||||
// font size, and the number of cards per row.
|
||||
let employeeCardPerLine = 1;
|
||||
let fontSizeMultiplication = 1;
|
||||
let searchBarHeight = 0;
|
||||
// for small screen the searchbar is higher
|
||||
if (screen.width <= MEDIAS_BREAKPOINTS[SIZES.SM].maxWidth){
|
||||
searchBarHeight += 38;
|
||||
} else if(screen.width <= MEDIAS_BREAKPOINTS[SIZES.MD].maxWidth){
|
||||
employeeCardPerLine = 2;
|
||||
} else if(screen.width <= MEDIAS_BREAKPOINTS[SIZES.LG].maxWidth){
|
||||
fontSizeMultiplication *= 1.25;
|
||||
employeeCardPerLine = 2;
|
||||
} else if (screen.width <= MEDIAS_BREAKPOINTS[SIZES.XL].maxWidth){
|
||||
fontSizeMultiplication *= 1.25;
|
||||
if (screen.width < 1400){ //grid breakpoint xxl
|
||||
employeeCardPerLine = 3;
|
||||
} else {
|
||||
employeeCardPerLine = 4;
|
||||
}
|
||||
} else {
|
||||
employeeCardPerLine = 4;
|
||||
if (screen.width <= 2560) {
|
||||
fontSizeMultiplication *= 1.35;
|
||||
} else {
|
||||
fontSizeMultiplication *= 2;
|
||||
}
|
||||
}
|
||||
let employeeCardHeight = 150 * fontSizeMultiplication;
|
||||
searchBarHeight += 62 * fontSizeMultiplication;
|
||||
let availableScreen = screen.height - searchBarHeight;
|
||||
return Math.trunc(availableScreen / employeeCardHeight) * employeeCardPerLine;
|
||||
}
|
||||
|
||||
async _onPagerChanged({ offset, limit }) {
|
||||
this.state.offset = offset;
|
||||
this.state.limit = limit;
|
||||
await this._fetchEmployeeData();
|
||||
}
|
||||
|
||||
async _fetchEmployeeData() {
|
||||
const domain = Domain.and([this.state.departmentDomain, this.state.searchDomain]).toList();
|
||||
const results = await rpc("/hr_attendance/employees_infos", {
|
||||
token: this.props.token,
|
||||
limit: this.state.limit,
|
||||
offset: this.state.offset,
|
||||
domain: domain,
|
||||
});
|
||||
this.state.employeesData.records = results.records;
|
||||
this.state.employeesData.count = results.length;
|
||||
}
|
||||
|
||||
async onDepartmentClick(departmentId = false){
|
||||
if (this.env.isSmall) {
|
||||
if (departmentId){
|
||||
const selectedDepartment = this.props.departments.find((department) => department.id === departmentId);
|
||||
this.departmentName = selectedDepartment.name;
|
||||
} else {
|
||||
this.departmentName = _t("All departments");
|
||||
}
|
||||
}
|
||||
if (departmentId){
|
||||
this.state.departmentDomain = [['department_id', '=', departmentId]];
|
||||
} else {
|
||||
this.state.departmentDomain = [];
|
||||
}
|
||||
this.state.offset = 0;
|
||||
await this._fetchEmployeeData();
|
||||
}
|
||||
|
||||
async onSearchInput(ev) {
|
||||
const searchInput = ev.target.value;
|
||||
if (searchInput.length){
|
||||
this.state.searchDomain = [['name', 'ilike', searchInput]];
|
||||
}else{
|
||||
this.state.searchDomain = [];
|
||||
}
|
||||
this.state.offset = 0;
|
||||
await this._fetchEmployeeData();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_attendance.public_kiosk_manual_selection">
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100">
|
||||
<div class="d-flex gap-2 p-2 bg-white" style="top: 0px;">
|
||||
<button
|
||||
t-on-click="() => this.props.onClickBack()"
|
||||
class="o_hr_attendance_back_button btn btn-secondary rounded-pill d-flex flex-row align-items-center"
|
||||
t-if="this.props.displayBackButton">
|
||||
<i class="oi oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
|
||||
Back
|
||||
</button>
|
||||
<div class="o_control_panel_main d-flex justify-content-between align-items-lg-center flex-grow-1">
|
||||
<div class="o_cp_searchview d-flex input-group h-100">
|
||||
<div class="d-flex flex-row align-items-center rounded-pill border flex-grow-1">
|
||||
<i class="o_searchview_icon d-print-none oi oi-search m-2" aria-label="Search..." title="Search..."/>
|
||||
<div class="o_searchview_input_container position-relative w-100">
|
||||
<input t-on-input="onSearchInput" type="text" class="d-print-none border-0" style="width: 95%;" placeholder="Search..."/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<Pager
|
||||
t-if="state.employeesData.count > state.limit"
|
||||
total="state.employeesData.count"
|
||||
offset="state.offset"
|
||||
limit="state.limit"
|
||||
onUpdate.bind="_onPagerChanged"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex h-100 flex-column flex-md-row bg-300">
|
||||
<t t-if="!env.isSmall">
|
||||
<div class="o_hr_kiosk_sidebar ps-2 py-2 overflow-auto">
|
||||
<div class="list-group">
|
||||
<a t-on-click="() => this.onDepartmentClick()" class="list-group-item py-3 list-group-item-action text-start">
|
||||
All
|
||||
</a>
|
||||
<t t-foreach="this.props.departments" t-as="dep" t-key="dep.id">
|
||||
<a t-on-click="() => this.onDepartmentClick(dep.id)" class="list-group-item py-3 list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<span class="text-truncate"><t t-esc="dep.name"/></span>
|
||||
<small class="badge bg-secondary rounded-pill ms-3"><t t-esc="dep.count"/></small>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<Dropdown>
|
||||
<button class="btn btn-light m-2 me-auto align-self-start">
|
||||
<span id="departmentButton" ><i class="fa fa-users me-2"/><t t-esc="departmentName"/></span>
|
||||
<i class="fa fa-caret-down ms-2"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<a t-on-click="() => this.onDepartmentClick()" class="d-flex text-start text-reset p-3">
|
||||
All
|
||||
</a>
|
||||
<DropdownItem
|
||||
t-foreach="this.props.departments"
|
||||
t-as="dep"
|
||||
t-key="dep.id"
|
||||
class="'py-3 d-flex justify-content-between align-items-center'"
|
||||
onSelected="() => this.onDepartmentClick(dep.id)">
|
||||
<span class="text-truncate"><t t-esc="dep.name"/></span>
|
||||
<small class="badge bg-secondary rounded-pill ms-3"><t t-esc="dep.count"/></small>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
<div class="o_hr_kiosk_manual_selection w-100 p-2 pt-0 pt-md-2 bg-300 overflow-auto">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 row-cols-xxl-4 g-2">
|
||||
<t t-foreach="this.state.employeesData.records" t-as="employee" t-key="employee.id">
|
||||
<div t-on-click="() => this.props.onSelectEmployee(employee.id)" class="col">
|
||||
<div class="card d-flex flex-column align-items-center h-100 p-2 pt-1">
|
||||
<div class="small ms-auto">
|
||||
<t t-if="employee.status == 'checked_in'">
|
||||
<span class="text-success">
|
||||
<t t-if="employee.mode == 'kiosk'">On site</t>
|
||||
<t t-else="">Online</t>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-danger">Absent</span>
|
||||
</t>
|
||||
</div>
|
||||
<img class="rounded-circle" alt="Employee Avatar" loading="lazy" t-attf-src="{{employee.avatar}}"/>
|
||||
<div class="d-flex flex-column align-items-center mt-2">
|
||||
<h6 class="mb-0" t-esc="employee.display_name"/>
|
||||
<p class="small text-muted text-center mb-1" t-if="employee.job_id" t-esc="employee.job_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { rpc } from "@web/core/network/rpc";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class Many2One extends Component {
|
||||
static template = "hr_attendance.Many2One";
|
||||
static components = { AutoComplete };
|
||||
static props = {
|
||||
token: String,
|
||||
update: Function,
|
||||
value: String,
|
||||
};
|
||||
|
||||
get sources() {
|
||||
return [{
|
||||
options: this.loadOptionsSource.bind(this),
|
||||
optionSlot: "option",
|
||||
}];
|
||||
}
|
||||
|
||||
async loadOptionsSource(input) {
|
||||
const employeeName = input;
|
||||
const data = await rpc('/hr_attendance/get_employees_without_badge', { token: this.props.token , name: employeeName });
|
||||
if (data?.status === "success") {
|
||||
return data.employees.map(emp => ({
|
||||
data: { id: emp.id },
|
||||
label: emp.name,
|
||||
onSelect: () => this.props.update({ id: emp.id, name: emp.name }),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_attendance.Many2One">
|
||||
<AutoComplete
|
||||
value="props.value"
|
||||
placeholder="props.placeholder || 'Select an employee'"
|
||||
sources="sources"
|
||||
onInput="props.update"
|
||||
dropdown="true"
|
||||
autofocus="true">
|
||||
<t t-set-slot="option" t-slot-scope="optionScope">
|
||||
<div class="d-flex align-items-center">
|
||||
<img t-attf-src="/web/image/hr.employee.public/{{optionScope.data.id}}/avatar_128"
|
||||
loading="lazy"
|
||||
class="rounded-circle me-2"
|
||||
width="24" height="24"/>
|
||||
<t t-out="optionScope.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</AutoComplete>
|
||||
<i class="fa fa-caret-down dropdown-caret-icon"></i>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { Component, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Many2One } from "./many2one/many2one";
|
||||
|
||||
export class NewEmployeeDialog extends Component {
|
||||
static components = { Dialog, Many2One };
|
||||
static template = "hr_attendance.NewEmployeeDialog";
|
||||
static props = {
|
||||
title: { type: String, optional: true },
|
||||
footer: { type: Boolean, optional: true },
|
||||
token: { type: String },
|
||||
}
|
||||
static defaultProps = {
|
||||
title: _t("Set-up"),
|
||||
footer: false,
|
||||
};
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
employeeName: "",
|
||||
badgeId: "",
|
||||
value: null,
|
||||
searchName: "",
|
||||
});
|
||||
}
|
||||
|
||||
onSelectEmployee(emp) {
|
||||
this.state.searchName = emp?.name ?? "";
|
||||
if( this.state.searchName == ""){
|
||||
this.state.value = null;
|
||||
}
|
||||
else{
|
||||
this.state.value = emp;
|
||||
}
|
||||
}
|
||||
|
||||
async onCreate() {
|
||||
if (!this.state.employeeName || this.state.employeeName.trim() === "") {
|
||||
this.notification.add(_t("Employee name is required."), {
|
||||
title: _t("Validation Error"),
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const is_created = await rpc('/hr_attendance/create_employee', {
|
||||
name: this.state.employeeName,
|
||||
token: this.props.token
|
||||
});
|
||||
if (is_created) {
|
||||
this.notification.add(_t("Employee created successfully!"), { type: "success",});
|
||||
this.props.close();
|
||||
} else {
|
||||
this.notification.add(_t("Failed to create employee."), {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(_t("Error creating employee: ") + error.message, {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onSetBadge() {
|
||||
const badge = this.state.badgeId?.trim();
|
||||
if (!this.state.value || !badge) {
|
||||
this.notification.add(_t("Please select an employee and enter a badge number."), {
|
||||
title: _t("Missing Data"),
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const employeeId = parseInt(this.state.value.id);
|
||||
const data = await rpc('/hr_attendance/set_badge', {
|
||||
employee_id: employeeId,
|
||||
badge: badge,
|
||||
token: this.props.token
|
||||
});
|
||||
if (data?.status === "success") {
|
||||
this.notification.add(_t("Badge assigned successfully!"), {
|
||||
type: "success",
|
||||
});
|
||||
this.props.close();
|
||||
} else {
|
||||
this.notification.add( _t("Error: ") + _t(data?.message),{
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_attendance.NewEmployeeDialog" owl="1">
|
||||
<Dialog title="props.title" footer="props.footer" bodyClass="'overflow-visible'">
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Create the name of the new employee below</label>
|
||||
<div class="d-flex">
|
||||
<input type="text" t-model="state.employeeName" class="form-control me-2" placeholder="e.g. John Doe"/>
|
||||
<button class="btn btn-primary" t-on-click="onCreate">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Employee Without Badge</label>
|
||||
<div class="o_select_employee">
|
||||
<div class="o_select_employee_display form-control">
|
||||
<img t-if="state.value" class="d-flex rounded" t-attf-src="/web/image/hr.employee.public/{{state.value.id}}/avatar_128"/>
|
||||
<div class="o_select_employee_option p-0">
|
||||
<Many2One value="state.searchName" update.bind="onSelectEmployee" token="props.token"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" t-model="state.badgeId" class="form-control" placeholder="Enter Badge Number"/>
|
||||
<button class="btn btn-primary" style="min-width: fit-content;" t-on-click="onSetBadge">Set the Badge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { Component, onWillStart, useState, onWillDestroy } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
export class KioskPinCode extends Component {
|
||||
static template = "hr_attendance.KioskPinConfirm";
|
||||
static props = {
|
||||
employeeData: { type: Object },
|
||||
onClickBack: { type: Function },
|
||||
onPinConfirm: { type: Function },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.padButtons = [
|
||||
...Array.from({ length: 9 }, (_, i) => [i + 1]), // [[1], ..., [9]]
|
||||
["C", "btn-warning"],
|
||||
[0],
|
||||
["OK", "btn-primary"],
|
||||
];
|
||||
this.state = useState({
|
||||
codePin: "",
|
||||
});
|
||||
this.lockPad = false;
|
||||
this.checkedIn = this.props.employeeData.attendance_state === 'checked_in';
|
||||
|
||||
const onKeyDown = async (ev) => {
|
||||
const allowedKeys = [...Array(10).keys()].reduce((acc, value) => { // { from '0': '0' ... to '9': '9' }
|
||||
acc[value] = value;
|
||||
return acc;
|
||||
}, {
|
||||
'Delete': 'C',
|
||||
'Enter': 'OK',
|
||||
'Backspace': null,
|
||||
});
|
||||
const key = ev.key;
|
||||
|
||||
if (!Object.keys(allowedKeys).includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (allowedKeys[key] !== null) {
|
||||
await this.onClickPadButton(allowedKeys[key]);
|
||||
}
|
||||
else {
|
||||
this.state.codePin = this.state.codePin.substring(0, this.state.codePin.length - 1);
|
||||
}
|
||||
}
|
||||
browser.addEventListener('keydown', onKeyDown);
|
||||
onWillStart(() => browser.addEventListener('keydown', onKeyDown))
|
||||
onWillDestroy(() => browser.removeEventListener('keydown', onKeyDown));
|
||||
}
|
||||
|
||||
async onClickPadButton(value) {
|
||||
if (this.lockPad) {
|
||||
return;
|
||||
}
|
||||
if (value === "C") {
|
||||
this.state.codePin = "";
|
||||
} else if (value === "OK") {
|
||||
this.lockPad = true;
|
||||
await this.props.onPinConfirm(this.props.employeeData.id, this.state.codePin)
|
||||
this.state.codePin = "";
|
||||
this.lockPad = false;
|
||||
} else {
|
||||
this.state.codePin += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.KioskPinConfirm">
|
||||
<t t-if="this.props.employeeData">
|
||||
<div class="o_hr_kiosk_mode_main d-flex flex-grow-1 justify-content-center align-items-center">
|
||||
<div class="card rounded-3">
|
||||
<div class="card-body rounded-3 text-center">
|
||||
<t t-call="hr_attendance.EmployeeBadge">
|
||||
<t t-set="employeeAvatar" t-value="this.props.employeeData.employee_avatar"/>
|
||||
<t t-set="employeeAvatarHeight" t-value="'60'"/>
|
||||
</t>
|
||||
<h3 class="mt-2 mb-1"><t t-esc="this.props.employeeData.employee_name"/></h3>
|
||||
<h5 class="text-muted my-0">
|
||||
Please enter your PIN to
|
||||
<t t-if="checkedIn">check out</t>
|
||||
<t t-else="">check in</t>
|
||||
</h5>
|
||||
<div class="o_hr_kiosk_mode_code mb-2">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
|
||||
<div class="row g-0 my-2" >
|
||||
<input t-att-value="state.codePin" class="o_hr_attendance_PINbox py-0 border-0 bg-white text-center fs-3" type="password" disabled="true"/>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-4" t-foreach="padButtons" t-as="btn" t-key="btn[0]">
|
||||
<a href="#" t-on-click="() => this.onClickPadButton(btn[0])" t-attf-class="o_hr_attendance_PINbox_button btn {{btn[1]? btn[1] : 'btn-secondary'}} d-flex align-items-center justify-content-center py-2 py-xl-3 rounded-1">
|
||||
<t t-esc="btn[0]"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_bottom my-4 mx-auto">
|
||||
<button class="btn btn-light btn-lg rounded-pill px-5" t-on-click="() => this.props.onClickBack()">
|
||||
<i class="oi oi-chevron-left me-2"/>
|
||||
<span>Go back</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-danger mx-3" role="alert">
|
||||
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
/**
|
||||
* Open the hr.attendance instead of the report list view.
|
||||
*/
|
||||
function openView(component, domain, views, context) {
|
||||
component.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: component.model.metaData.title,
|
||||
res_model: 'hr.attendance',
|
||||
views: [[false, "list"], [false, "form"]],
|
||||
view_mode: "list",
|
||||
target: "current",
|
||||
context,
|
||||
domain,
|
||||
});
|
||||
}
|
||||
|
||||
export class AttendanceReportGraphController extends graphView.Controller {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
openView(domain, views, context) {
|
||||
openView(this, domain, views, context);
|
||||
}
|
||||
}
|
||||
|
||||
viewRegistry.add("attendance_report_graph", {
|
||||
...graphView,
|
||||
Controller: AttendanceReportGraphController
|
||||
});
|
||||
|
||||
export class AttendanceReportPivotController extends pivotView.Controller {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
openView(domain, views, context) {
|
||||
openView(this, domain, views, context);
|
||||
}
|
||||
}
|
||||
|
||||
viewRegistry.add("attendance_report_pivot", {
|
||||
...pivotView,
|
||||
Controller: AttendanceReportPivotController
|
||||
});
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
odoo.define('hr_attendance.greeting_message', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
var time = require('web.time');
|
||||
var field_utils = require('web.field_utils');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
|
||||
var GreetingMessage = AbstractAction.extend({
|
||||
contentTemplate: 'HrAttendanceGreetingMessage',
|
||||
|
||||
events: {
|
||||
"click .o_hr_attendance_button_dismiss": function() { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
|
||||
},
|
||||
|
||||
init: function(parent, action) {
|
||||
var self = this;
|
||||
this._super.apply(this, arguments);
|
||||
this.activeBarcode = true;
|
||||
this.kioskDelay = action.kiosk_delay;
|
||||
|
||||
// if no correct action given (due to an erroneous back or refresh from the browser), we set the dismiss button to return
|
||||
// to the (likely) appropriate menu, according to the user access rights
|
||||
if(!action.attendance) {
|
||||
this.activeBarcode = false;
|
||||
this.getSession().user_has_group('hr_attendance.group_hr_attendance_user').then(function(has_group) {
|
||||
if(has_group) {
|
||||
self.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
|
||||
} else {
|
||||
self.next_action = 'hr_attendance.hr_attendance_action_my_attendances';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.next_action = action.next_action || 'hr_attendance.hr_attendance_action_my_attendances';
|
||||
// no listening to barcode scans if we aren't coming from the kiosk mode (and thus not going back to it with next_action)
|
||||
if (this.next_action != 'hr_attendance.hr_attendance_action_kiosk_mode' && this.next_action.tag != 'hr_attendance_kiosk_mode') {
|
||||
this.activeBarcode = false;
|
||||
}
|
||||
|
||||
this.attendance = action.attendance;
|
||||
// We receive the check in/out times in UTC
|
||||
// This widget only deals with display, which should be in browser's TimeZone
|
||||
this.attendance.check_in = this.attendance.check_in && moment.utc(this.attendance.check_in).local();
|
||||
this.attendance.check_out = this.attendance.check_out && moment.utc(this.attendance.check_out).local();
|
||||
this.previous_attendance_change_date = action.previous_attendance_change_date && moment.utc(action.previous_attendance_change_date).local();
|
||||
|
||||
// check in/out times displayed in the greeting message template.
|
||||
this.format_time = time.getLangTimeFormat();
|
||||
this.attendance.check_in_time = this.attendance.check_in && this.attendance.check_in.format(this.format_time);
|
||||
this.attendance.check_out_time = this.attendance.check_out && this.attendance.check_out.format(this.format_time);
|
||||
|
||||
// extra hours amount displayed in the greeting message template.
|
||||
this.show_total_overtime = action.show_total_overtime;
|
||||
this.total_overtime_float = action.total_overtime; // Used for comparison in template
|
||||
this.total_overtime = field_utils.format.float_time(this.total_overtime_float);
|
||||
this.today_overtime_float = action.overtime_today;
|
||||
this.today_overtime = field_utils.format.float_time(this.today_overtime_float);
|
||||
|
||||
if (action.hours_today) {
|
||||
var duration = moment.duration(action.hours_today, "hours");
|
||||
this.hours_today = duration.hours() + ' hours, ' + duration.minutes() + ' minutes';
|
||||
}
|
||||
|
||||
this.employee_name = action.employee_name;
|
||||
this.attendanceBarcode = action.barcode;
|
||||
},
|
||||
|
||||
start: function() {
|
||||
if (this.attendance) {
|
||||
this.attendance.check_out ? this.farewell_message() : this.welcome_message();
|
||||
}
|
||||
if (this.activeBarcode) {
|
||||
core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
welcome_message: function() {
|
||||
var self = this;
|
||||
var now = this.attendance.check_in.clone();
|
||||
if (this.kioskDelay > 0) {
|
||||
this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, this.kioskDelay);
|
||||
}
|
||||
|
||||
if (now.hours() < 5) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Good night"));
|
||||
} else if (now.hours() < 12) {
|
||||
if (now.hours() < 8 && Math.random() < 0.3) {
|
||||
if (Math.random() < 0.75) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("The early bird catches the worm"));
|
||||
} else {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("First come, first served"));
|
||||
}
|
||||
} else {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Good morning"));
|
||||
}
|
||||
} else if (now.hours() < 17){
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Good afternoon"));
|
||||
} else if (now.hours() < 23){
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Good evening"));
|
||||
} else {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Good night"));
|
||||
}
|
||||
if(this.previous_attendance_change_date){
|
||||
var last_check_out_date = this.previous_attendance_change_date.clone();
|
||||
if(now - last_check_out_date > 24*7*60*60*1000){
|
||||
this.$('.o_hr_attendance_random_message').html(_t("Glad to have you back, it's been a while!"));
|
||||
} else {
|
||||
if(Math.random() < 0.02){
|
||||
this.$('.o_hr_attendance_random_message').html(_t("If a job is worth doing, it is worth doing well!"));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
farewell_message: function() {
|
||||
var self = this;
|
||||
var now = this.attendance.check_out.clone();
|
||||
if (this.kioskDelay > 0) {
|
||||
this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, this.kioskDelay);
|
||||
}
|
||||
|
||||
if(this.previous_attendance_change_date){
|
||||
var last_check_in_date = this.previous_attendance_change_date.clone();
|
||||
if(now - last_check_in_date > 1000*60*60*12){
|
||||
this.$('.o_hr_attendance_warning_message').show().append(_t("<b>Warning! Last check in was over 12 hours ago.</b><br/>If this isn't right, please contact Human Resource staff"));
|
||||
if (this.return_to_main_menu) {
|
||||
clearTimeout(this.return_to_main_menu);
|
||||
}
|
||||
this.activeBarcode = false;
|
||||
} else if(now - last_check_in_date > 1000*60*60*8){
|
||||
this.$('.o_hr_attendance_random_message').html(_t("Another good day's work! See you soon!"));
|
||||
}
|
||||
}
|
||||
|
||||
if (now.hours() < 12) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Have a good day!"));
|
||||
} else if (now.hours() < 14) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Have a nice lunch!"));
|
||||
if (Math.random() < 0.05) {
|
||||
this.$('.o_hr_attendance_random_message').html(_t("Eat breakfast as a king, lunch as a merchant and supper as a beggar"));
|
||||
} else if (Math.random() < 0.06) {
|
||||
this.$('.o_hr_attendance_random_message').html(_t("An apple a day keeps the doctor away"));
|
||||
}
|
||||
} else if (now.hours() < 17) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Have a good afternoon"));
|
||||
} else {
|
||||
if (now.hours() < 18 && Math.random() < 0.2) {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Early to bed and early to rise, makes a man healthy, wealthy and wise"));
|
||||
} else {
|
||||
this.$('.o_hr_attendance_message_message').append(_t("Have a good evening"));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_onBarcodeScanned: function(barcode) {
|
||||
var self = this;
|
||||
if (this.attendanceBarcode !== barcode){
|
||||
if (this.return_to_main_menu) { // in case of multiple scans in the greeting message view, delete the timer, a new one will be created.
|
||||
clearTimeout(this.return_to_main_menu);
|
||||
}
|
||||
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
|
||||
const kioskDelay = this.kioskDelay;
|
||||
this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'attendance_scan',
|
||||
args: [barcode, ],
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.action) {
|
||||
self.do_action(result.action);
|
||||
} else if (result.warning) {
|
||||
self.displayNotification({ title: result.warning, type: 'danger' });
|
||||
if (kioskDelay > 0) {
|
||||
setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, kioskDelay);
|
||||
}
|
||||
}
|
||||
}, function () {
|
||||
if (kioskDelay > 0) {
|
||||
setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, kioskDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
|
||||
clearTimeout(this.return_to_main_menu);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
core.action_registry.add('hr_attendance_greeting_message', GreetingMessage);
|
||||
|
||||
return GreetingMessage;
|
||||
|
||||
});
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
odoo.define('hr_attendance.kiosk_confirm', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
var field_utils = require('web.field_utils');
|
||||
var QWeb = core.qweb;
|
||||
|
||||
const session = require('web.session');
|
||||
|
||||
|
||||
var KioskConfirm = AbstractAction.extend({
|
||||
events: {
|
||||
"click .o_hr_attendance_back_button": function () { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
|
||||
"click .o_hr_attendance_sign_in_out_icon": _.debounce(function () {
|
||||
var self = this;
|
||||
this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'attendance_manual',
|
||||
args: [[this.employee_id], this.next_action],
|
||||
context: session.user_context,
|
||||
})
|
||||
.then(function(result) {
|
||||
if (result.action) {
|
||||
self.do_action(result.action);
|
||||
} else if (result.warning) {
|
||||
self.displayNotification({ title: result.warning, type: 'danger' });
|
||||
}
|
||||
});
|
||||
}, 200, true),
|
||||
'click .o_hr_attendance_pin_pad_button_0': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 0); },
|
||||
'click .o_hr_attendance_pin_pad_button_1': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 1); },
|
||||
'click .o_hr_attendance_pin_pad_button_2': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 2); },
|
||||
'click .o_hr_attendance_pin_pad_button_3': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 3); },
|
||||
'click .o_hr_attendance_pin_pad_button_4': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 4); },
|
||||
'click .o_hr_attendance_pin_pad_button_5': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 5); },
|
||||
'click .o_hr_attendance_pin_pad_button_6': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 6); },
|
||||
'click .o_hr_attendance_pin_pad_button_7': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 7); },
|
||||
'click .o_hr_attendance_pin_pad_button_8': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 8); },
|
||||
'click .o_hr_attendance_pin_pad_button_9': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 9); },
|
||||
'click .o_hr_attendance_pin_pad_button_C': function() { this.$('.o_hr_attendance_PINbox').val(''); },
|
||||
'click .o_hr_attendance_pin_pad_button_ok': function() { this._send_pin_debounced() },
|
||||
},
|
||||
|
||||
_sendPin: function() {
|
||||
var self = this;
|
||||
this.$('.o_hr_attendance_pin_pad_button_ok').attr("disabled", "disabled");
|
||||
this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'attendance_manual',
|
||||
args: [[this.employee_id], this.next_action, this.$('.o_hr_attendance_PINbox').val()],
|
||||
context: session.user_context,
|
||||
})
|
||||
.then(function(result) {
|
||||
self.pin_is_send = true
|
||||
if (result.action) {
|
||||
self.do_action(result.action);
|
||||
} else if (result.warning) {
|
||||
self.displayNotification({ title: result.warning, type: 'danger' });
|
||||
self.$('.o_hr_attendance_PINbox').val('');
|
||||
setTimeout( function() { self.$('.o_hr_attendance_pin_pad_button_ok').removeAttr("disabled"); }, 500);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
init: function (parent, action) {
|
||||
this._super.apply(this, arguments);
|
||||
this.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
|
||||
this.employee_id = action.employee_id;
|
||||
this.employee_name = action.employee_name;
|
||||
this.employee_state = action.employee_state;
|
||||
this.employee_hours_today = field_utils.format.float_time(action.employee_hours_today);
|
||||
|
||||
this.pin_is_send = false
|
||||
this._send_pin_debounced = _.debounce(this._sendPin, 200, true);
|
||||
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
const allowedKeys = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Backspace', 'Enter', 'Delete'];
|
||||
const key = ev.key;
|
||||
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const pinBox = this.$('.o_hr_attendance_PINbox');
|
||||
|
||||
if (key.length == 1) {
|
||||
pinBox.val(pinBox.val() + key);
|
||||
}
|
||||
else if (key == 'Enter' && !this.pin_is_send) {
|
||||
this._send_pin_debounced();
|
||||
}
|
||||
else if (key == 'Backspace') {
|
||||
pinBox.val(pinBox.val().substring(0, pinBox.val().length - 1));
|
||||
}
|
||||
else { // Delete
|
||||
pinBox.val('');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
start: function () {
|
||||
var self = this;
|
||||
this.getSession().user_has_group('hr_attendance.group_hr_attendance_use_pin').then(function(has_group){
|
||||
self.use_pin = has_group;
|
||||
self.$el.html(QWeb.render("HrAttendanceKioskConfirm", {widget: self}));
|
||||
self.start_clock();
|
||||
});
|
||||
return self._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
start_clock: function () {
|
||||
this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
|
||||
// First clock refresh before interval to avoid delay
|
||||
this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
clearInterval(this.clock_start);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
core.action_registry.add('hr_attendance_kiosk_confirm', KioskConfirm);
|
||||
|
||||
return KioskConfirm;
|
||||
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
odoo.define('hr_attendance.kiosk_mode', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var ajax = require('web.ajax');
|
||||
var core = require('web.core');
|
||||
var Session = require('web.session');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
|
||||
var KioskMode = AbstractAction.extend({
|
||||
events: {
|
||||
"click .o_hr_attendance_button_employees": function() {
|
||||
this.do_action('hr_attendance.hr_employee_attendance_action_kanban', {
|
||||
additional_context: {'no_group_by': true},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
start: function () {
|
||||
var self = this;
|
||||
core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
|
||||
self.session = Session;
|
||||
const company_id = this.session.user_context.allowed_company_ids[0];
|
||||
var def = this._rpc({
|
||||
model: 'res.company',
|
||||
method: 'search_read',
|
||||
args: [[['id', '=', company_id]], ['name', 'attendance_kiosk_mode', 'attendance_barcode_source']],
|
||||
})
|
||||
.then(function (companies){
|
||||
self.company_name = companies[0].name;
|
||||
self.company_image_url = self.session.url('/web/image', {model: 'res.company', id: company_id, field: 'logo',});
|
||||
self.kiosk_mode = companies[0].attendance_kiosk_mode;
|
||||
self.barcode_source = companies[0].attendance_barcode_source;
|
||||
self.$el.html(QWeb.render("HrAttendanceKioskMode", {widget: self}));
|
||||
self.start_clock();
|
||||
});
|
||||
// Make a RPC call every day to keep the session alive
|
||||
self._interval = window.setInterval(this._callServer.bind(this), (60*60*1000*24));
|
||||
return Promise.all([def, this._super.apply(this, arguments)]);
|
||||
},
|
||||
|
||||
on_attach_callback: function () {
|
||||
// Stop the bus_service to avoid notifications in kiosk mode
|
||||
this.call('bus_service', 'stop');
|
||||
$('body').find('.o_ChatWindowHeader_commandClose').click();
|
||||
},
|
||||
|
||||
_onBarcodeScanned: function(barcode) {
|
||||
var self = this;
|
||||
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
|
||||
this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'attendance_scan',
|
||||
args: [barcode, ],
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.action) {
|
||||
self.do_action(result.action);
|
||||
} else if (result.warning) {
|
||||
self.displayNotification({ title: result.warning, type: 'danger' });
|
||||
core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
|
||||
}
|
||||
}, function () {
|
||||
core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
|
||||
});
|
||||
},
|
||||
|
||||
start_clock: function() {
|
||||
this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
|
||||
// First clock refresh before interval to avoid delay
|
||||
this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
|
||||
clearInterval(this.clock_start);
|
||||
clearInterval(this._interval);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
_callServer: function () {
|
||||
// Make a call to the database to avoid the auto close of the session
|
||||
return ajax.rpc("/hr_attendance/kiosk_keepalive", {});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
core.action_registry.add('hr_attendance_kiosk_mode', KioskMode);
|
||||
|
||||
return KioskMode;
|
||||
|
||||
});
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
odoo.define('hr_attendance.my_attendances', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
var field_utils = require('web.field_utils');
|
||||
|
||||
const session = require('web.session');
|
||||
|
||||
var MyAttendances = AbstractAction.extend({
|
||||
contentTemplate: 'HrAttendanceMyMainMenu',
|
||||
events: {
|
||||
"click .o_hr_attendance_sign_in_out_icon": _.debounce(function() {
|
||||
this.update_attendance();
|
||||
}, 200, true),
|
||||
},
|
||||
|
||||
willStart: function () {
|
||||
var self = this;
|
||||
|
||||
var def = this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'search_read',
|
||||
args: [[['user_id', '=', this.getSession().uid]], ['attendance_state', 'name', 'hours_today']],
|
||||
context: session.user_context,
|
||||
})
|
||||
.then(function (res) {
|
||||
self.employee = res.length && res[0];
|
||||
if (res.length) {
|
||||
self.hours_today = field_utils.format.float_time(self.employee.hours_today);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all([def, this._super.apply(this, arguments)]);
|
||||
},
|
||||
|
||||
update_attendance: function () {
|
||||
var self = this;
|
||||
this._rpc({
|
||||
model: 'hr.employee',
|
||||
method: 'attendance_manual',
|
||||
args: [[self.employee.id], 'hr_attendance.hr_attendance_action_my_attendances'],
|
||||
context: session.user_context,
|
||||
})
|
||||
.then(function(result) {
|
||||
if (result.action) {
|
||||
self.do_action(result.action);
|
||||
} else if (result.warning) {
|
||||
self.displayNotification({ title: result.warning, type: 'danger' });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
core.action_registry.add('hr_attendance_my_attendances', MyAttendances);
|
||||
|
||||
return MyAttendances;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { App, whenReady, Component, useState } from "@odoo/owl";
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { KioskManualSelection } from "@hr_attendance/components/manual_selection/manual_selection";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { getTemplate } from "@web/core/templates";
|
||||
import { _t, appTranslateFn } from "@web/core/l10n/translation";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService, useBus } from "@web/core/utils/hooks";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import { KioskGreetings } from "@hr_attendance/components/greetings/greetings";
|
||||
import { KioskPinCode } from "@hr_attendance/components/pin_code/pin_code";
|
||||
import { KioskBarcodeScanner } from "@hr_attendance/components/kiosk_barcode/kiosk_barcode";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { isIosApp } from "@web/core/browser/feature_detection";
|
||||
import { DocumentationLink } from "@web/views/widgets/documentation_link/documentation_link";
|
||||
import { NewEmployeeDialog } from "@hr_attendance/components/new_employee_dialog/new_employee_dialog";
|
||||
import { session } from "@web/session";
|
||||
|
||||
class kioskAttendanceApp extends Component{
|
||||
static template = "hr_attendance.public_kiosk_app";
|
||||
static props = {
|
||||
token: { type: String },
|
||||
companyId: { type: Number },
|
||||
companyName: { type: String },
|
||||
departments: { type: Array },
|
||||
kioskMode: { type: String },
|
||||
barcodeSource: { type: String },
|
||||
fromTrialMode: { type: Boolean },
|
||||
deviceTrackingEnabled: { type: Boolean },
|
||||
};
|
||||
static components = {
|
||||
KioskBarcodeScanner,
|
||||
CardLayout,
|
||||
KioskManualSelection,
|
||||
KioskGreetings,
|
||||
KioskPinCode,
|
||||
MainComponentsContainer,
|
||||
DocumentationLink,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
this.barcode = useService("barcode");
|
||||
this.notification = useService("notification");
|
||||
this.ui = useService("ui");
|
||||
this.companyImageUrl = url("/web/binary/company_logo", {
|
||||
company: this.props.companyId,
|
||||
});
|
||||
this.state = useState({
|
||||
active_display: "settings",
|
||||
displayDemoMessage: browser.localStorage.getItem("hr_attendance.ShowDemoMessage") !== "false",
|
||||
});
|
||||
this.lockScanner = false;
|
||||
if (this.props.kioskMode === 'settings' || this.props.fromTrialMode){
|
||||
this.manualKioskMode = false;
|
||||
useBus(this.barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
|
||||
}
|
||||
else if (this.props.kioskMode !== 'manual') {
|
||||
useBus(this.barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
|
||||
this.state.active_display = "main";
|
||||
this.manualKioskMode = false;
|
||||
} else {
|
||||
this.manualKioskMode = true;
|
||||
this.state.active_display = "manual";
|
||||
}
|
||||
}
|
||||
|
||||
switchDisplay(screen) {
|
||||
const displays = ["main", "greet", "manual", "pin", "settings"];
|
||||
if (displays.includes(screen)) {
|
||||
this.state.active_display = screen;
|
||||
} else {
|
||||
this.state.active_display = "main";
|
||||
}
|
||||
}
|
||||
|
||||
newSetUp() {
|
||||
this.dialogService.add(NewEmployeeDialog, { 'token': this.props.token });
|
||||
}
|
||||
|
||||
async setSetting(mode) {
|
||||
await rpc("/hr_attendance/set_settings", {
|
||||
token: this.props.token,
|
||||
mode: mode,
|
||||
});
|
||||
this.props.kioskMode = mode;
|
||||
if (mode !== "manual") {
|
||||
this.manualKioskMode = false;
|
||||
this.state.active_display = "main";
|
||||
this.props.kioskMode = mode;
|
||||
} else {
|
||||
this.manualKioskMode = true;
|
||||
this.state.active_display = "manual";
|
||||
this.props.kioskMode = "manual";
|
||||
}
|
||||
}
|
||||
|
||||
async kioskConfirm(employeeId){
|
||||
const employee = await rpc('attendance_employee_data',
|
||||
{
|
||||
'token': this.props.token,
|
||||
'employee_id': employeeId
|
||||
})
|
||||
if (employee && employee.employee_name){
|
||||
if (employee.use_pin){
|
||||
this.employeeData = employee
|
||||
this.switchDisplay('pin')
|
||||
}else{
|
||||
await this.onManualSelection(employeeId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kioskReturn() {
|
||||
if (this.state.active_display === "settings"){
|
||||
history.back();
|
||||
} else if (
|
||||
(["manual", "barcode"].includes(this.props.kioskMode) ||
|
||||
(this.props.kioskMode === "barcode_manual" &&
|
||||
this.state.active_display === "main")) &&
|
||||
this.props.fromTrialMode
|
||||
) {
|
||||
this.switchDisplay("settings");
|
||||
} else if (this.props.kioskMode === 'manual') {
|
||||
this.switchDisplay("manual");
|
||||
} else {
|
||||
this.switchDisplay("main");
|
||||
}
|
||||
}
|
||||
|
||||
displayNotification(text){
|
||||
this.notification.add(text, { type: "danger" });
|
||||
}
|
||||
|
||||
async makeRpcWithGeolocation(route, params) {
|
||||
if (!this.props.deviceTrackingEnabled || !navigator.geolocation || isIosApp()) {
|
||||
// iOS app lacks permissions or tracking disabled
|
||||
return rpc(route, { ...params });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async ({ coords: { latitude, longitude } }) => {
|
||||
const result = await rpc(route, {
|
||||
...params,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
resolve(result);
|
||||
},
|
||||
async (err) => {
|
||||
const result = await rpc(route, {
|
||||
...params
|
||||
});
|
||||
resolve(result);
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async onManualSelection(employeeId, enteredPin) {
|
||||
const result = await this.makeRpcWithGeolocation('manual_selection',
|
||||
{
|
||||
'token': this.props.token,
|
||||
'employee_id': employeeId,
|
||||
'pin_code': enteredPin
|
||||
})
|
||||
if (result && result.attendance) {
|
||||
this.employeeData = result
|
||||
this.switchDisplay('greet')
|
||||
}else{
|
||||
if (enteredPin){
|
||||
this.displayNotification(_t("Wrong Pin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onBarcodeScanned(barcode){
|
||||
if (this.lockScanner || this.state.active_display !== 'main') {
|
||||
return;
|
||||
}
|
||||
this.lockScanner = true;
|
||||
this.ui.block();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await rpc("attendance_barcode_scanned", {
|
||||
barcode: barcode,
|
||||
token: this.props.token,
|
||||
});
|
||||
|
||||
if (result && result.employee_name) {
|
||||
this.employeeData = result;
|
||||
this.switchDisplay("greet");
|
||||
} else {
|
||||
this.displayNotification(
|
||||
_t("No employee corresponding to Badge ID '%(barcode)s.'", { barcode })
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.displayNotification(error.data.message);
|
||||
} finally {
|
||||
this.lockScanner = false;
|
||||
this.ui.unblock();
|
||||
}
|
||||
}
|
||||
|
||||
removeDemoMessage() {
|
||||
this.state.displayDemoMessage = false;
|
||||
browser.localStorage.setItem("hr_attendance.ShowDemoMessage", "false");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPublicKioskAttendance(document, kiosk_backend_info) {
|
||||
await whenReady();
|
||||
const env = makeEnv();
|
||||
await startServices(env);
|
||||
session.server_version_info = kiosk_backend_info.server_version_info;
|
||||
const app = new App(kioskAttendanceApp, {
|
||||
getTemplate,
|
||||
env: env,
|
||||
props:
|
||||
{
|
||||
token : kiosk_backend_info.token,
|
||||
companyId: kiosk_backend_info.company_id,
|
||||
companyName: kiosk_backend_info.company_name,
|
||||
departments: kiosk_backend_info.departments,
|
||||
kioskMode: kiosk_backend_info.kiosk_mode,
|
||||
barcodeSource: kiosk_backend_info.barcode_source,
|
||||
fromTrialMode: kiosk_backend_info.from_trial_mode,
|
||||
deviceTrackingEnabled: kiosk_backend_info.device_tracking_enabled,
|
||||
},
|
||||
dev: env.debug,
|
||||
translateFn: appTranslateFn,
|
||||
translatableAttributes: ["data-tooltip"],
|
||||
});
|
||||
return app.mount(document.body);
|
||||
}
|
||||
export default { kioskAttendanceApp, createPublicKioskAttendance };
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.companyHeader">
|
||||
<img t-att-src="companyImageUrl" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center"/>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.BarcodeScanner" t-inherit="barcodes.BarcodeScanner" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('o_barcode_mobile_container')]" position="replace">
|
||||
<div class="o_hr_attendance_kiosk d-flex justify-content-around" t-att-class="{'position-relative' : !isDisplayStandalone}">
|
||||
<button t-if="isBarcodeScannerSupported" t-on-click="openMobileScanner" class="o_mobile_barcode btn btn-light btn-lg p-5 rounded-3" t-att-class="{'position-absolute' : !isDisplayStandalone}">
|
||||
<i class="fa fa-3x fa-barcode mb-3"/>
|
||||
<span class="d-block">Scan your badge</span>
|
||||
</button>
|
||||
<a t-if="!isDisplayStandalone" class="o_hr_attendance_install_btn btn btn-secondary d-flex align-items-center justify-content-center fw-bolder position-relative"
|
||||
t-att-style="props.fromTrialMode ? 'left: 25%;' : 'left: 115%;'"
|
||||
t-att-href="installURL" target="_blank">Install</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.PublicKiosksSettingScreen">
|
||||
<div class="align-self-center" t-att-class="{'h2 mt-5': !env.isSmall, 'my-3 text-center h5': env.isSmall}">
|
||||
Choose how to record attendances
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_main d-flex m-auto" t-att-class="{'gap-5': !env.isSmall, 'gap-3 flex-column': env.isSmall}">
|
||||
<div class="d-flex flex-column align-items-center text-center gap-2" t-att-style="!env.isSmall ? 'max-width: min-content;' : ''">
|
||||
<button
|
||||
t-on-click="() => this.setSetting('manual')"
|
||||
class="btn btn-light"
|
||||
t-att-class="{
|
||||
'd-flex flex-row rounded-2 ps-2 align-items-center gap-2' : env.isSmall,
|
||||
'btn-lg rounded-3': !env.isSmall
|
||||
}">
|
||||
<t t-if="env.isSmall">
|
||||
<img src="/hr_attendance/static/img/tablet-pin.svg" alt="Manual Attendance" width="40%"/>
|
||||
<h6 class="m-0">Manually (optional PIN)</h6>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img src="/hr_attendance/static/img/tablet-pin.svg" height="170px" alt="Manual Attendance"/>
|
||||
</t>
|
||||
</button>
|
||||
<t t-if="!env.isSmall">
|
||||
<h5 class="m-0">Select on Tablet</h5>
|
||||
<em>with optional PIN code</em>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-center text-center gap-2" t-att-style="!env.isSmall ? 'max-width: min-content;' : ''">
|
||||
<button
|
||||
t-on-click="() => this.setSetting('barcode')"
|
||||
class="btn btn-light"
|
||||
t-att-class="{
|
||||
'flex-row d-flex rounded-2 ps-2 align-items-center gap-2' : env.isSmall,
|
||||
'btn-lg rounded-3': !env.isSmall
|
||||
}">
|
||||
<t t-if="env.isSmall">
|
||||
<img src="/hr_attendance/static/img/tablet-cam.svg" alt="Attendance with barcode" width="40%"/>
|
||||
<h6 class="m-0">Badge with Barcode</h6>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img src="/hr_attendance/static/img/tablet-cam.svg" height="170px" alt="Attendance with barcode"/>
|
||||
</t>
|
||||
</button>
|
||||
<t t-if="!env.isSmall">
|
||||
<h5 class="m-0 w-75">Badge with Barcode on Tablet</h5>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-center text-center gap-2" t-att-style="!env.isSmall ? 'max-width: min-content;' : ''">
|
||||
<button
|
||||
t-on-click="() => this.setSetting('barcode_manual')"
|
||||
class="btn btn-light"
|
||||
t-att-class="{
|
||||
'flex-row d-flex rounded-2 ps-2 align-items-center gap-2' : env.isSmall,
|
||||
'btn-lg rounded-3': !env.isSmall
|
||||
}">
|
||||
<t t-if="env.isSmall">
|
||||
<img src="/hr_attendance/static/img/tablet-rfid.svg" alt="Manual Attendance or with barcode" width="40%"/>
|
||||
<h6 class="m-0">RFID Token with reader</h6>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img src="/hr_attendance/static/img/tablet-rfid.svg" height="170px"
|
||||
alt="Manual Attendance or with barcode"/>
|
||||
</t>
|
||||
</button>
|
||||
<t t-if="!env.isSmall">
|
||||
<h5 class="m-0 w-75">RFID Token with reader on tablet</h5>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.public_kiosk_app">
|
||||
<MainComponentsContainer/>
|
||||
<CardLayout fromTrialMode="this.props.fromTrialMode" companyImageUrl="this.companyImageUrl" kioskReturn.bind="kioskReturn" activeDisplay = "this.state.active_display">
|
||||
<t t-if="this.state.active_display === 'settings'">
|
||||
<t t-call="hr_attendance.PublicKiosksSettingScreen"/>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'main'">
|
||||
<t t-if="state.displayDemoMessage">
|
||||
<div
|
||||
class="alert alert-info flex-row justify-content-between d-flex"
|
||||
t-att-class="{'align-self-center col-6': !env.isSmall}"
|
||||
t-att-style="!env.isSmall? 'min-width: fit-content;' : ''">
|
||||
<div>
|
||||
Connect an RFID reader, and scan a token.
|
||||
<DocumentationLink path="'/applications/hr/attendances/hardware.html'" label.translate="'Read the Documentation'" alertLink="true"/>/
|
||||
<DocumentationLink path="'https://duckduckgo.com/?q=rfid+reader'" label.translate="'Buy an RFID Device'" alertLink="true"/>
|
||||
</div>
|
||||
<button t-on-click="removeDemoMessage" type="button" class="btn-close float-end ms-2" title="Close"/>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_hr_kiosk_mode_main d-flex flex-column gap-3 m-auto">
|
||||
<t t-if="this.props.kioskMode !== 'manual'">
|
||||
<KioskBarcodeScanner
|
||||
kioskMode="this.props.kioskMode"
|
||||
fromTrialMode="this.props.fromTrialMode"
|
||||
token="this.props.token"
|
||||
barcodeSource="this.props.barcodeSource"
|
||||
onBarcodeScanned="(ev) => this.onBarcodeScanned(ev)"/>
|
||||
<div class="o_hr_kiosk_mode_main mt-5" t-if="this.props.fromTrialMode">
|
||||
<button t-on-click="() => this.newSetUp()" class="btn btn-light btn-lg rounded-3 mt-5">
|
||||
<span >New Set-up (Employee/Badge)</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_hr_kiosk_mode_bottom d-flex flex-column flex-md-row gap-2 align-items-center justify-content-between my-5">
|
||||
<t t-if="this.props.kioskMode !== 'barcode'">
|
||||
<button t-on-click="() => this.switchDisplay('manual')" class="btn btn-light btn-lg rounded-3">
|
||||
<i class="fa fa-user-o me-2"/>
|
||||
<span >Identify Manually</span>
|
||||
</button>
|
||||
<span>
|
||||
Powered by <img height="32" src="/web/static/img/logo.png" alt="Odoo Logo"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_hr_kiosk_mode_bottom mx-auto">
|
||||
Powered by <img height="32" src="/web/static/img/logo.png" alt="Odoo Logo"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'manual'">
|
||||
<KioskManualSelection
|
||||
displayBackButton="!this.manualKioskMode || this.props.fromTrialMode"
|
||||
departments="this.props.departments"
|
||||
onSelectEmployee="(e) => this.kioskConfirm(e)"
|
||||
onClickBack="() => this.kioskReturn()"
|
||||
token="this.props.token"/>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'greet'">
|
||||
<KioskGreetings employeeData="this.employeeData" kioskReturn="() => this.kioskReturn(true)"/>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'pin'">
|
||||
<KioskPinCode
|
||||
employeeData="this.employeeData"
|
||||
onPinConfirm="(id, pin) => this.onManualSelection(id, pin)"
|
||||
onClickBack="() => this.kioskReturn()"/>
|
||||
</t>
|
||||
</CardLayout>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
|
||||
.o_hr_attendance_kiosk_mode, .o_hr_attendance_clock {
|
||||
@include media-breakpoint-down(md) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_clock {
|
||||
position: relative;
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include o-position-absolute(0, 0); // Fine-tuned by margin classes
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_mode {
|
||||
@include media-breakpoint-up(md) {
|
||||
min-width: map-get($grid-breakpoints, 'md') * .6;
|
||||
|
||||
.o_hr_attendance_user_badge {
|
||||
@include o-position-absolute(auto, $border-width * -1, 100%, $border-width * -1); // Compensate card's border
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_back_button_md {
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%); // Once migrate to BS5, can be replaced by 'translate-middle' class.
|
||||
}
|
||||
|
||||
.o_hr_attendance_PINbox_button {
|
||||
aspect-ratio: 1.8;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
$font-size-base: 1rem !default;
|
||||
$font-size-root: $o-attendance-font-size-base !default;
|
||||
$small-font-size: .75em !default;
|
||||
|
||||
$spacer: 1rem !default;
|
||||
|
||||
$border-radius: .75rem !default;
|
||||
$border-radius-lg: 1rem !default;
|
||||
$border-color: $o-gray-300 !default;
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
:root {
|
||||
@include media-breakpoint-up(lg) {
|
||||
// Allow to handle root font size whether or not the website is installed
|
||||
--bs-root-font-size: #{$o-attendance-font-size-root-lg};
|
||||
--root-font-size: #{$o-attendance-font-size-root-lg};
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xxl) {
|
||||
--bs-root-font-size: #{$o-attendance-font-size-root-xxl};
|
||||
--root-font-size: #{$o-attendance-font-size-root-xxl};
|
||||
}
|
||||
|
||||
// UW
|
||||
@media (min-width: 2560px) {
|
||||
--bs-root-font-size: #{$o-attendance-font-size-root-uw};
|
||||
--root-font-size: #{$o-attendance-font-size-root-uw};
|
||||
}
|
||||
|
||||
//4K
|
||||
@media screen and (min-width: 3839px), (min-height: 3839px) {
|
||||
--bs-root-font-size: #{$o-attendance-font-size-root-4k};
|
||||
--root-font-size: #{$o-attendance-font-size-root-4k};
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_mode {
|
||||
--kiosk-searchBar-height: 70px;
|
||||
--kiosk-searchBar-height--mobile: 110px;
|
||||
--kiosk-sidebar-width: 320px;
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_company_image {
|
||||
max-height: 10vh;
|
||||
@include media-breakpoint-up(sm) {
|
||||
max-height: 7 vh;
|
||||
}
|
||||
}
|
||||
|
||||
.o_mobile_barcode {
|
||||
width: max-content;
|
||||
top: -200%;
|
||||
}
|
||||
|
||||
.o_hr_attendance_install_btn {
|
||||
transform: scale(0.75);
|
||||
left: 115%;
|
||||
top: -200%;
|
||||
}
|
||||
|
||||
.o_hr_kiosk_sidebar {
|
||||
height: calc(100dvh - var(--kiosk-searchBar-height));
|
||||
min-width: var(--kiosk-sidebar-width);
|
||||
}
|
||||
|
||||
.o_hr_kiosk_manual_selection {
|
||||
height: calc(100dvh - var(--kiosk-searchBar-height));
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
grid-template-rows: max-content;
|
||||
|
||||
img {
|
||||
width:4rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
height: calc(100dvh - var(--kiosk-searchBar-height--mobile));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.o_hr_attendance_kiosk_card {
|
||||
min-width: 75%;
|
||||
@include media-breakpoint-up(md) {
|
||||
min-width: 50%;
|
||||
}
|
||||
@include media-breakpoint-up(xl) {
|
||||
min-width: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_mode {
|
||||
.o_pager_counter {
|
||||
min-width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_kiosk_url_media{
|
||||
a{
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
.o_attendance_form .o_content{
|
||||
overflow: hidden !important;
|
||||
}
|
||||
// Shared with web client and login screen
|
||||
.o_attendance_background{
|
||||
background: {
|
||||
size: cover;
|
||||
attachment: fixed;
|
||||
color: var(--homeMenu-bg-color, #{$o-gray-200});
|
||||
image: var(--homeMenu-bg-image, url("/hr_attendance/static/img/background-light.svg"));
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_body {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_select_employee{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@include media-breakpoint-down(sm){
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.o_select_employee_display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $input-focus-border-color;
|
||||
box-shadow: 0 0 0 0.25rem rgba($primary, 0.25);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_select_employee_option {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
.o-autocomplete--input {
|
||||
width: 100%;
|
||||
font-family: $input-font-family;
|
||||
@include font-size($input-font-size);
|
||||
font-weight: $input-font-weight;
|
||||
line-height: $input-line-height;
|
||||
color: $input-color;
|
||||
border: 0px;
|
||||
appearance: none;
|
||||
@include transition($input-transition);
|
||||
|
||||
&:focus {
|
||||
outline: none; // remove default blue outline
|
||||
}
|
||||
}
|
||||
|
||||
.o-autocomplete--dropdown-menu {
|
||||
position: absolute !important;
|
||||
width:110%;
|
||||
left:-5% !important;
|
||||
top:42px !important;
|
||||
max-height: 300px!important;
|
||||
overflow-y: auto;
|
||||
.dropdown-item {
|
||||
padding: map-get($spacers, 2);
|
||||
font-size: $input-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.o-autocomplete--dropdown-item a[aria-selected="true"]{
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.dropdown-caret-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
$o-attendance-font-size-base: 1rem !default;
|
||||
$o-attendance-font-size-root-lg: $o-attendance-font-size-base * 1.25 !default;
|
||||
$o-attendance-font-size-root-xxl: $o-attendance-font-size-base * 1.35 !default;
|
||||
$o-attendance-font-size-root-uw: $o-attendance-font-size-base * 2 !default;
|
||||
$o-attendance-font-size-root-4k: $o-attendance-font-size-base * 2.6 !default;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
.o_hr_barcode_bg {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_barcode_main {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background: white;
|
||||
.o_barcode_mobile_container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: -40px;
|
||||
img {
|
||||
height: 185px;
|
||||
width: 275px;
|
||||
}
|
||||
// In order to have the o_mobile_barcode button on both the image and the label,
|
||||
// We use negative margin at the bottom and 0 opacity (since not needed in the view)
|
||||
.o_mobile_barcode {
|
||||
opacity: 0;
|
||||
height: 225px;
|
||||
width: 275px;
|
||||
bottom: -40px;
|
||||
}
|
||||
.o_barcode_laser {
|
||||
height: 3px;
|
||||
width: 125%;
|
||||
left: -12.5%;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0 1em 1em .75em;
|
||||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
flex: 0 0 auto;
|
||||
width: 550px;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.6);
|
||||
font-size: 1.2em;
|
||||
padding: 0 1em 1em .75em;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.o_nocontent_help:has(.oe_view_nocontent_attendance) {
|
||||
max-width: none !important;
|
||||
margin: 0% !important;
|
||||
padding: 0% !important;
|
||||
}
|
||||
|
||||
.o_view_nocontent:has(.oe_view_nocontent_attendance_mobile) {
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
.o_nocontent_help:has(.oe_view_nocontent_attendance_mobile) {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
|
||||
export class AttendanceActionHelper extends Component {
|
||||
static template = "hr_attendance.AttendanceActionHelper";
|
||||
static props = ["noContentHelp"];
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.state = useState({
|
||||
hasDemoData: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
this.isHrUser = await user.hasGroup("hr.group_hr_user");
|
||||
this.hasAttendanceRight = await user.hasGroup("hr_attendance.group_hr_attendance_user");
|
||||
if (this.hasAttendanceRight && this.isHrUser){
|
||||
this.state.hasDemoData = await this.orm.call("hr.attendance", "has_demo_data", []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadAttendanceScenario() {
|
||||
this.actionService.doAction("hr_attendance.action_load_demo_data");
|
||||
}
|
||||
|
||||
LoadTryKiosk() {
|
||||
this.actionService.doAction("hr_attendance.action_try_kiosk");
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="hr_attendance.AttendanceActionHelper">
|
||||
<div class="o_view_nocontent">
|
||||
<div class="o_nocontent_help">
|
||||
<t t-if="hasAttendanceRight and isHrUser">
|
||||
<t t-if="env.isSmall">
|
||||
<t t-call="hr_attendance.AttendanceActionHelperMobileScreen"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="hr_attendance.AttendanceActionHelperRegularScreen"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No attendance records found
|
||||
</p>
|
||||
<p>
|
||||
The attendance records of your employees will be displayed here.
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.AttendanceActionHelperMobileScreen">
|
||||
<div class="d-flex flex-column align py-2 gap-4 oe_view_nocontent_attendance_mobile">
|
||||
<h3>
|
||||
Ready to track attendances ?
|
||||
</h3>
|
||||
<a type="object" t-on-click="() => this.LoadTryKiosk()">
|
||||
<img src="/hr_attendance/static/img/mock-tablet.png" height="180" class="mb-2"/>
|
||||
<div class="row justify-content-center mt-1">
|
||||
<div class="btn btn-primary d-block col-6">
|
||||
Try the kiosk
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div>
|
||||
<img src="/hr_attendance/static/img/attendance_dot.gif" height="180" class="mb-2"/>
|
||||
<h3 class="mt-2">
|
||||
<em>Try the top
|
||||
<i class="fa fa-circle text-danger" role="img" aria-label="Attendance"/>
|
||||
icon (e.g for work from home)
|
||||
</em>
|
||||
</h3>
|
||||
</div>
|
||||
<t t-if="!state.hasDemoData">
|
||||
<div class="d-flex gap-3 align-items-center or-separator">
|
||||
<hr class="flex-grow-1"/>
|
||||
or
|
||||
<hr class="flex-grow-1"/>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<h3 class="pb-1">
|
||||
Try the backend and reporting:
|
||||
</h3>
|
||||
<div class="row justify-content-center">
|
||||
<a type="object" class="btn btn-secondary d-block col-6" t-on-click="() => this.loadAttendanceScenario()">
|
||||
Load sample data
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.AttendanceActionHelperRegularScreen">
|
||||
<p class="oe_view_nocontent_attendance">
|
||||
<h3 class="py-2">
|
||||
Ready to track attendances ?
|
||||
</h3>
|
||||
<div class="d-flex align py-2">
|
||||
<a class="mx-5" type="object" t-on-click="() => this.LoadTryKiosk()">
|
||||
<img src="/hr_attendance/static/img/mock-tablet.png" height="180" class="mb-2"/>
|
||||
<div class="row justify-content-center mt-1">
|
||||
<div class="btn btn-primary d-block col-6">
|
||||
Try the kiosk
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mx-5" >
|
||||
<img src="/hr_attendance/static/img/attendance_dot.gif" height="180" class="mb-2"/>
|
||||
<h3 class="mt-2"><em>Try the top
|
||||
<i class="fa fa-circle text-danger" role="img" aria-label="Attendance"/>
|
||||
icon (e.g for work from home)</em></h3>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="!state.hasDemoData">
|
||||
<div class="d-flex gap-3 align-items-center or-separator">
|
||||
<hr class="flex-grow-1"/>
|
||||
or
|
||||
<hr class="flex-grow-1"/>
|
||||
</div>
|
||||
<div class="px-2 py-2">
|
||||
<h3 class="m-2 py-2">
|
||||
Try the backend and reporting:
|
||||
</h3>
|
||||
<div class="row justify-content-center">
|
||||
<a type="object" class="btn btn-secondary d-block col-2" t-on-click="() => this.loadAttendanceScenario()">
|
||||
Load sample data
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</p>
|
||||
</t>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { AttendanceActionHelper } from "@hr_attendance/views/attendance_helper_view";
|
||||
|
||||
export class AttendanceListRenderer extends ListRenderer {
|
||||
static template = "hr_attendance.AttendanceListRenderer";
|
||||
static components = {
|
||||
...AttendanceListRenderer.components,
|
||||
AttendanceActionHelper,
|
||||
};
|
||||
|
||||
/** @override **/
|
||||
get showNoContentHelper() {
|
||||
// Rows's length need to be lower than 6 to avoid nocontent overlapping
|
||||
return super.showNoContentHelper && this.props.list.count < 6 ;
|
||||
}
|
||||
};
|
||||
|
||||
export class AttendanceListModel extends listView.Model {
|
||||
|
||||
/** @override **/
|
||||
async load(params = {}) {
|
||||
const activeDomainParam = params.domain?.some((index) => Array.isArray(index) && index[0] == "employee_id.active");
|
||||
if (!activeDomainParam) {
|
||||
params.domain?.push(["employee_id.active", "=", true]);
|
||||
}
|
||||
return super.load(params);
|
||||
}
|
||||
}
|
||||
|
||||
export const attendanceListView = {
|
||||
...listView,
|
||||
Renderer: AttendanceListRenderer,
|
||||
Model: AttendanceListModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("attendance_list_view", attendanceListView);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="hr_attendance.AttendanceListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
|
||||
<ActionHelper position="replace"/>
|
||||
<table position="after">
|
||||
<t t-if="showNoContentHelper">
|
||||
<AttendanceActionHelper noContentHelp="props.noContentHelp"/>
|
||||
</t>
|
||||
</table>
|
||||
</t>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
|
||||
|
||||
export class BadgeScanner extends Component {
|
||||
static template = "hr.BadgeScannerTemplate"
|
||||
static components = { BarcodeScanner };
|
||||
static props = {
|
||||
...standardActionServiceProps,
|
||||
};
|
||||
setup() {
|
||||
this.employeeId = this.props.action?.context?.active_id;
|
||||
this.notification = useService("notification");
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
onWillStart(async () => {
|
||||
this.employee = await this.orm.read("hr.employee", [this.employeeId], ["name"]);
|
||||
});
|
||||
}
|
||||
|
||||
async onBarcodeScanned(barcode) {
|
||||
if (!barcode) {
|
||||
this.notification.add(_t("No barcode received"), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.employeeId) {
|
||||
this.notification.add(_t("Missing Employee ID"), {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
try{
|
||||
await this.orm.write("hr.employee", [this.employee[0].id], { barcode: barcode })
|
||||
this.env.config.historyBack();
|
||||
this.notification.add((_t("Badge updated: ") + barcode), { type: "success" });
|
||||
}
|
||||
catch(error){
|
||||
this.notification.add(_t("Failed to update badge: ") + error?.data?.message, {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
this.env.config.historyBack();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("employee_barcode_scanner", BadgeScanner);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr.BadgeScannerTemplate">
|
||||
<div class="o_hr_barcode_bg o_home_menu_background">
|
||||
<div class="o_hr_barcode_main container d-flex flex-column h-100 h-sm-auto bg-view shadow">
|
||||
<div class="d-flex align-items-center justify-content-between my-3">
|
||||
<a href="#" class="btn btn-light" t-on-click.prevent="() => this.onClickBack()">
|
||||
<i class="oi oi-chevron-left fa-lg"></i>
|
||||
</a>
|
||||
<span class="fs-2 me-auto ms-2">Scan Badge</span>
|
||||
</div>
|
||||
<div class="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
|
||||
<BarcodeScanner onBarcodeScanned="(barcode) => this.onBarcodeScanned(barcode)" />
|
||||
<div class="my-5 text-center">
|
||||
<h5 class="mt8 text-muted">Scan or Tap your badge</h5>
|
||||
<t t-if="employee">
|
||||
<h4 class="text-primary mt-3">For <t t-out="employee[0].name"/></h4>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<template xml:space="preserve">
|
||||
<t t-name="PresenceIndicator">
|
||||
<div id="oe_hr_attendance_status" class="fa fa-circle me-1 text-400 " role="img" aria-label="Available" title="Available">
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceCardLayout">
|
||||
<div class="o_hr_attendance_kiosk_mode_container o_home_menu_background d-flex flex-column align-items-center justify-content-center h-100 text-center">
|
||||
<span class="o_hr_attendance_kiosk_backdrop position-absolute top-0 start-0 end-0 bottom-0 bg-black-25"/>
|
||||
<div class="o_hr_attendance_clock bg-black-50 p-3 py-md-2 m-0 mt-md-5 me-md-5 h2 text-white font-monospace"/>
|
||||
<div t-attf-class="o_hr_attendance_kiosk_mode flex-grow-1 flex-md-grow-0 card pb-3 px-0 px-lg-5 {{kioskModeClasses ? kioskModeClasses : '' }}">
|
||||
<div class="card-body d-flex flex-column p-0 p-md-4">
|
||||
<t t-out="bodyContent"></t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceUserBadge">
|
||||
<div class="o_hr_attendance_user_badge o_home_menu_background d-flex align-items-end justify-content-center flex-grow-1 pt-5 pt-md-4 bg-odoo">
|
||||
<img class="img rounded-circle mb-n5" t-attf-src="/web/image?model=hr.employee.public&field=avatar_128&id=#{userId}" t-att-title="userName" height="80" t-att-alt="userName"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceCheckInOutButtons">
|
||||
<div class="flex-grow-1">
|
||||
<button t-attf-class="o_hr_attendance_sign_in_out_icon btn btn-{{ checked_in ? 'warning' : 'success' }} align-self-center px-5 py-3 mt-4 mb-2">
|
||||
<span class="align-middle fs-2 me-3 text-white" t-if="!checked_in">Check IN</span>
|
||||
<i t-attf-class="fa fa-4x fa-sign-{{ checked_in ? 'out' : 'in' }} align-middle"/>
|
||||
<span class="align-middle fs-2 ms-3" t-if="checked_in">Check OUT</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceKioskMode">
|
||||
<t t-call="HrAttendanceCardLayout">
|
||||
<t t-set="kioskModeClasses" t-translation="off">o_barcode_main pt-5</t>
|
||||
<t t-set="bodyContent">
|
||||
<h2 class="mb-2"><small>Welcome to</small> <t t-esc="widget.company_name"/></h2>
|
||||
<img t-attf-src="{{widget.company_image_url}}" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center img img-fluid mb-3" width="200"/>
|
||||
<div class="o_hr_attendance_kiosk_welcome_row d-flex flex-column pb-5">
|
||||
<div class="col-md-5 mt-5 mb-5 mb-md-0 align-self-center" t-if="widget.kiosk_mode != 'manual'">
|
||||
<img src="/barcodes/static/img/barcode.png" alt="Barcode" style="width: 115px;height: 60px"/>
|
||||
<h6 class="mt-2 text-muted">Scan your badge</h6>
|
||||
</div>
|
||||
<div class="mt-5 align-self-end" t-if="widget.kiosk_mode == 'barcode_manual'">
|
||||
<button class="o_hr_attendance_button_employees btn btn-link">
|
||||
Identify Manually
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5 align-self-center" t-if="widget.kiosk_mode == 'manual'">
|
||||
<button class="o_hr_attendance_button_employees btn btn-primary px-5 py-3 mt-4 mb-2">
|
||||
<span class="fs-2">Identify Manually</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceMyMainMenu">
|
||||
<t t-call="HrAttendanceCardLayout">
|
||||
<t t-set="bodyContent">
|
||||
<t t-if="widget.employee">
|
||||
<t t-set="checked_in" t-value="widget.employee.attendance_state=='checked_in'"/>
|
||||
|
||||
<t t-call="HrAttendanceUserBadge">
|
||||
<t t-set="userId" t-value="widget.employee.id"/>
|
||||
<t t-set="userName" t-value="widget.employee.name"/>
|
||||
</t>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="mt-5" t-esc="widget.employee.name"/>
|
||||
<h3><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
|
||||
<h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.hours_today"/></h4>
|
||||
</div>
|
||||
|
||||
<t t-call="HrAttendanceCheckInOutButtons"/>
|
||||
</t>
|
||||
<div class="alert alert-warning" t-else="">
|
||||
<b>Warning</b> : Your user should be linked to an employee to use attendance.<br/> Please contact your administrator.
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceKioskConfirm">
|
||||
<t t-call="HrAttendanceCardLayout">
|
||||
<t t-set="bodyContent">
|
||||
<t t-set="checked_in" t-value="widget.employee_state=='checked_in'"/>
|
||||
|
||||
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
|
||||
<i class="fa fa-chevron-left me-2"/> Go back
|
||||
</button>
|
||||
|
||||
<t t-if="widget.employee_id" t-call="HrAttendanceUserBadge">
|
||||
<t t-set="userId" t-value="widget.employee_id"/>
|
||||
<t t-set="userName" t-value="widget.employee_name"/>
|
||||
</t>
|
||||
|
||||
<button class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
|
||||
<i class="fa fa-2x fa-fw fa-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
|
||||
<div t-if="widget.employee_id" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb8"><t t-esc="widget.employee_name"/></h1>
|
||||
<h3 class="mt8 mb24"><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
|
||||
<h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.employee_hours_today"/></h4>
|
||||
|
||||
<t t-if="!widget.use_pin" t-call="HrAttendanceCheckInOutButtons"/>
|
||||
|
||||
<t t-else="">
|
||||
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checked_in">check out</b><b t-else="">check in</b></h3>
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
|
||||
<div class="row g-0" >
|
||||
<div class="col-12 mb8 mt8">
|
||||
<input class="o_hr_attendance_PINbox border-0 bg-white fs-1 text-center" type="password" disabled="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<t t-foreach="['1', '2', '3', '4', '5', '6', '7', '8', '9', ['C', 'btn-warning'], '0', ['ok', 'btn-primary']]" t-as="btn_name">
|
||||
<div class="col-4 p-1">
|
||||
<a href="#" t-attf-class="o_hr_attendance_PINbox_button btn {{btn_name[1]? btn_name[1] : 'btn-secondary border'}} btn-block btn-lg {{ 'o_hr_attendance_pin_pad_button_' + btn_name[0] }} d-flex align-items-center justify-content-center">
|
||||
<t t-esc="btn_name[0]"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="alert alert-danger mx-3" role="alert">
|
||||
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="HrAttendanceGreetingMessage">
|
||||
<t t-call="HrAttendanceCardLayout">
|
||||
<t t-set="bodyContent">
|
||||
<t t-set="checked_in" t-value="widget.employee_state=='checked_in'"/>
|
||||
|
||||
<t t-if="widget.attendance">
|
||||
<t t-call="HrAttendanceUserBadge">
|
||||
<t t-set="userId" t-value="widget.attendance.employee_id[0]"/>
|
||||
<t t-set="userName" t-value="widget.employee_name"/>
|
||||
</t>
|
||||
|
||||
<div t-if="widget.attendance.check_out" class="flex-grow-1">
|
||||
<h1 class="mt-5">Goodbye <t t-esc="widget.employee_name"/>!</h1>
|
||||
<h2 class="o_hr_attendance_message_message mt4 mb24"/>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked out at <b><t t-esc="widget.attendance.check_out_time"/></b>
|
||||
<br/><b><t t-esc="widget.hours_today"/></b>
|
||||
</div>
|
||||
<t t-if="widget.show_total_overtime">
|
||||
<div t-att-class="'alert ' + (widget.today_overtime_float >= 0 ? 'alert-success' : 'alert-danger') + ' h3 mx-3'" role="status">
|
||||
Extra hours today:
|
||||
<span t-esc="widget.today_overtime"/>
|
||||
</div>
|
||||
<t t-if="widget.total_overtime_float > 0">
|
||||
Total extra hours: <span t-esc="widget.total_overtime"/>
|
||||
</t>
|
||||
</t>
|
||||
<h3 class="o_hr_attendance_random_message fst-italic mb24"/>
|
||||
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
|
||||
</div>
|
||||
<div t-else="" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb0">Welcome <t t-esc="widget.employee_name"/>!</h1>
|
||||
<h2 class="o_hr_attendance_message_message mt4 mb24"/>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked in at <b><t t-esc="widget.attendance.check_in_time"/></b>
|
||||
</div>
|
||||
<h3 class="o_hr_attendance_random_message mb24"/>
|
||||
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss align-self-center btn btn-primary btn-lg px-5 py-3">
|
||||
<span class="fs-2" t-if="widget.attendance.check_out">Goodbye</span>
|
||||
<span class="fs-2" t-else="">OK</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="flex-grow-1">
|
||||
<div class="alert alert-warning mt-5 mx-3" role="alert">
|
||||
<h4 class="alert-heading">Invalid request</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg fs-2 px-5 py-3">
|
||||
<i class="fa fa-chevron-left me-2"/>
|
||||
<span class="fs-2">Go back</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { waitFor } from "@odoo/hoot-dom";
|
||||
import { Deferred } from "@odoo/hoot-mock";
|
||||
import { KioskBarcodeScanner } from "@hr_attendance/components/kiosk_barcode/kiosk_barcode";
|
||||
import { contains, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { defineMailModels, mockGetMedia } from "@mail/../tests/mail_test_helpers";
|
||||
import { uuid } from "@web/core/utils/strings";
|
||||
|
||||
defineMailModels();
|
||||
|
||||
test.tags("desktop");
|
||||
test("KioskBarcodeScanner can be opened and closed", async () => {
|
||||
|
||||
mockGetMedia();
|
||||
const isBarcodeScannerOpened = new Deferred();
|
||||
patchWithCleanup(KioskBarcodeScanner.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
isBarcodeScannerOpened.resolve(true);
|
||||
},
|
||||
});
|
||||
|
||||
await mountWithCleanup(KioskBarcodeScanner, {
|
||||
props: {
|
||||
token: uuid(),
|
||||
barcodeSource: "environment",
|
||||
kioskMode: "manual",
|
||||
fromTrialMode: false,
|
||||
onBarcodeScanned: () => {},
|
||||
},
|
||||
});
|
||||
await contains("button.o_mobile_barcode").click();
|
||||
await waitFor(".modal-body video");
|
||||
await contains(`.oi-arrow-left`).click();
|
||||
expect(await isBarcodeScannerOpened).toBe(true);
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, {
|
||||
/**
|
||||
* Simulate the initialization of the attendance systray data
|
||||
* @override
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
if (route === "/hr_attendance/attendance_user_data") {
|
||||
return Promise.resolve({
|
||||
"id": 1,
|
||||
"employee_name": "Mitchell Admin",
|
||||
"employee_avatar": false,
|
||||
"hours_today": 0.0019,
|
||||
"total_overtime": 0,
|
||||
"last_attendance_worked_hours": 0.0019,
|
||||
"last_check_in": "2023-10-02 07:54:31",
|
||||
"attendance_state": "checked_out",
|
||||
"hours_previously_today": 0,
|
||||
"kiosk_delay": 10000,
|
||||
"attendance": {
|
||||
"check_in": "2023-10-02 07:54:31",
|
||||
"check_out": "2023-10-02 07:54:38"
|
||||
},
|
||||
"overtime_today": 0,
|
||||
"use_pin": false
|
||||
})
|
||||
}
|
||||
return super._performRPC(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
odoo.define('hr_attendance.tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
var core = require('web.core');
|
||||
|
||||
var MyAttendances = require('hr_attendance.my_attendances');
|
||||
var KioskMode = require('hr_attendance.kiosk_mode');
|
||||
var GreetingMessage = require('hr_attendance.greeting_message');
|
||||
|
||||
|
||||
QUnit.module('HR Attendance', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
'hr.employee': {
|
||||
fields: {
|
||||
name: {string: 'Name', type: 'char'},
|
||||
attendance_state: {
|
||||
string: 'State',
|
||||
type: 'selection',
|
||||
selection: [['checked_in', "In"], ['checked_out', "Out"]],
|
||||
default: 1,
|
||||
},
|
||||
user_id: {string: 'user ID', type: 'integer'},
|
||||
barcode: {string:'barcode', type: 'integer'},
|
||||
hours_today: {string:'Hours today', type: 'float'},
|
||||
overtime: {string: 'Overtime', type: 'float'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
name: "Employee A",
|
||||
attendance_state: 'checked_out',
|
||||
user_id: 1,
|
||||
barcode: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Employee B",
|
||||
attendance_state: 'checked_out',
|
||||
user_id: 2,
|
||||
barcode: 2,
|
||||
}],
|
||||
},
|
||||
'res.company': {
|
||||
fields: {
|
||||
name: {string: 'Name', type: 'char'},
|
||||
attendance_kiosk_mode: {type: 'char'},
|
||||
attendance_barcode_source: {type: 'char'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
name: "Company A",
|
||||
attendance_kiosk_mode: 'barcode_manual',
|
||||
attendance_barcode_source: 'front',
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
QUnit.module('My attendances (client action)');
|
||||
|
||||
QUnit.test('simple rendering', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var $target = $('#qunit-fixture');
|
||||
var clientAction = new MyAttendances(null, {});
|
||||
await testUtils.mock.addMockEnvironment(clientAction, {
|
||||
data: this.data,
|
||||
session: {
|
||||
uid: 1,
|
||||
},
|
||||
});
|
||||
await clientAction.appendTo($target);
|
||||
|
||||
assert.strictEqual(clientAction.$('.o_hr_attendance_kiosk_mode h1').text(), 'Employee A',
|
||||
"should have rendered the client action without crashing");
|
||||
|
||||
clientAction.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Attendance Kiosk Mode Test', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var $target = $('#qunit-fixture');
|
||||
var self = this;
|
||||
var rpcCount = 0;
|
||||
var clientAction = new KioskMode(null, {});
|
||||
await testUtils.mock.addMockEnvironment(clientAction, {
|
||||
data: this.data,
|
||||
session: {
|
||||
uid: 1,
|
||||
user_context: {
|
||||
allowed_company_ids: [1],
|
||||
}
|
||||
},
|
||||
mockRPC: function(route, args) {
|
||||
if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
|
||||
|
||||
rpcCount++;
|
||||
return Promise.resolve(self.data['hr.employee'].records[0]);
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
await clientAction.appendTo($target);
|
||||
core.bus.trigger('barcode_scanned', 1);
|
||||
core.bus.trigger('barcode_scanned', 1);
|
||||
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
|
||||
|
||||
core.bus.trigger('barcode_scanned', 2);
|
||||
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
|
||||
|
||||
clientAction.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Attendance Greeting Message Test', async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
var $target = $('#qunit-fixture');
|
||||
var self = this;
|
||||
var rpcCount = 0;
|
||||
|
||||
var clientActions = [];
|
||||
let greetingMessageCreated;
|
||||
async function createGreetingMessage (target, barcode){
|
||||
var action = {
|
||||
attendance: {
|
||||
check_in: "2018-09-20 13:41:13",
|
||||
employee_id: [barcode],
|
||||
},
|
||||
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
|
||||
barcode: barcode,
|
||||
};
|
||||
var clientAction = new GreetingMessage(null, action);
|
||||
await testUtils.mock.addMockEnvironment(clientAction, {
|
||||
data: self.data,
|
||||
session: {
|
||||
uid: 1,
|
||||
company_id: 1,
|
||||
},
|
||||
mockRPC: function(route, args) {
|
||||
if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
|
||||
rpcCount++;
|
||||
action.attendance.employee_id = [args.args[0], 'Employee'];
|
||||
/*
|
||||
if rpc have been made, a new instance is created to simulate the same behaviour
|
||||
as functional flow.
|
||||
*/
|
||||
greetingMessageCreated = createGreetingMessage (target, args.args[0]);
|
||||
return Promise.resolve({action: action});
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
});
|
||||
await clientAction.appendTo(target);
|
||||
|
||||
clientActions.push(clientAction);
|
||||
}
|
||||
|
||||
// init - mock coming from kiosk
|
||||
await createGreetingMessage ($target, 1);
|
||||
await testUtils.nextMicrotaskTick();
|
||||
assert.strictEqual(clientActions.length, 1, 'Number of clientAction must = 1.');
|
||||
|
||||
core.bus.trigger('barcode_scanned', 1);
|
||||
/*
|
||||
As action is given when instantiate GreetingMessage, we simulate that we come from the KioskMode
|
||||
So rescanning the same barcode won't lead to another RPC.
|
||||
*/
|
||||
assert.strictEqual(clientActions.length, 1, 'Number of clientActions must = 1.');
|
||||
assert.strictEqual(rpcCount, 0, 'RPC call should not have been done.');
|
||||
|
||||
core.bus.trigger('barcode_scanned', 2);
|
||||
await greetingMessageCreated;
|
||||
assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
|
||||
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
|
||||
core.bus.trigger('barcode_scanned', 2);
|
||||
await testUtils.nextMicrotaskTick();
|
||||
assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
|
||||
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
|
||||
|
||||
core.bus.trigger('barcode_scanned', 1);
|
||||
await greetingMessageCreated;
|
||||
assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
|
||||
core.bus.trigger('barcode_scanned', 1);
|
||||
await testUtils.nextMicrotaskTick();
|
||||
assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
|
||||
assert.strictEqual(rpcCount, 2, 'RPC call should have been done only twice.');
|
||||
|
||||
_.each(clientActions.reverse(), function(clientAction) {
|
||||
clientAction.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||