This commit is contained in:
yaschalew
2026-07-10 10:37:01 +03:00
parent 0a88e2bb1d
commit dcb2d98503
595 changed files with 90792 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
.ethiopian-picker-surface .calendars {
width: 100% !important;
border: 0;
border-radius: 0.5rem;
color: rgb(31 41 55);
font-family: inherit;
font-size: 0.875rem;
box-shadow: none;
}
.ethiopian-picker-surface .calendars-nav,
.ethiopian-picker-surface .calendars-ctrl {
background: transparent;
border-bottom: 1px solid rgb(229 231 235);
padding: 0.35rem 0.4rem;
}
.ethiopian-picker-surface .calendars-month {
width: 100%;
border: 0;
}
.ethiopian-picker-surface .calendars-month-header,
.ethiopian-picker-surface .calendars-month-header select,
.ethiopian-picker-surface .calendars-month-header input {
height: 2.1rem;
background: transparent;
color: rgb(17 24 39);
font-weight: 600;
}
.ethiopian-picker-surface .calendars-month th,
.ethiopian-picker-surface .calendars-month td {
border: 0;
padding: 0.2rem;
}
.ethiopian-picker-surface .calendars-month th {
color: rgb(107 114 128);
font-weight: 500;
font-size: 0.75rem;
}
.ethiopian-picker-surface .calendars-month a,
.ethiopian-picker-surface .calendars-month span {
border-radius: 0.35rem;
background: transparent;
color: rgb(17 24 39);
padding: 0.38rem 0;
}
.ethiopian-picker-surface .calendars-month a:hover {
background: rgb(220 252 231);
color: rgb(22 101 52);
}
.ethiopian-picker-surface .calendars-month td .calendars-today {
background: rgb(220 252 231);
color: rgb(22 101 52);
}
.ethiopian-picker-surface .calendars-month td .calendars-selected {
background: rgb(34 197 94);
color: rgb(255 255 255);
}
.ethiopian-picker-surface .calendars-month td .calendars-other-month {
color: rgb(156 163 175);
}
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
.calendars-popup .calendars-nav .calendars-cmd-prev,
.calendars-popup .calendars-nav .calendars-cmd-next,
.calendars-popup .calendars-nav .calendars-cmd-prevJump,
.calendars-popup .calendars-nav .calendars-cmd-nextJump {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
border: 1px solid rgb(187 247 208);
border-radius: 0.5rem;
background: rgb(240 253 244);
color: rgb(21 128 61);
line-height: 1;
transition: all 0.2s ease;
}
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
.calendars-popup .calendars-nav .calendars-cmd-prev:hover,
.calendars-popup .calendars-nav .calendars-cmd-next:hover,
.calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
.calendars-popup .calendars-nav .calendars-cmd-nextJump:hover {
background: rgb(34 197 94);
border-color: rgb(34 197 94);
color: rgb(255 255 255);
}
.ethiopian-picker-surface .calendars-nav,
.calendars-popup .calendars-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.ethiopian-picker-surface .calendars-nav .calendars-cmd,
.calendars-popup .calendars-nav .calendars-cmd {
float: none;
width: auto;
padding-left: 0;
padding-right: 0;
}
.ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
.calendars-popup .calendars-nav .calendars-cmd-today {
flex: 1;
border-radius: 0.5rem;
color: rgb(21 128 61);
font-weight: 600;
text-align: center;
}
.ethiopian-picker-surface .calendars-nav .calendars-cmd.calendars-disabled,
.calendars-popup .calendars-nav .calendars-cmd.calendars-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dark .ethiopian-picker-surface .calendars,
[data-theme="dark"] .ethiopian-picker-surface .calendars,
.dark .calendars-popup .calendars,
[data-theme="dark"] .calendars-popup .calendars {
background: rgb(17 24 39);
color: rgb(229 231 235);
border-color: rgb(55 65 81);
}
.dark .ethiopian-picker-surface .calendars-nav,
.dark .ethiopian-picker-surface .calendars-ctrl,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav,
[data-theme="dark"] .ethiopian-picker-surface .calendars-ctrl,
.dark .calendars-popup .calendars-nav,
.dark .calendars-popup .calendars-ctrl,
[data-theme="dark"] .calendars-popup .calendars-nav,
[data-theme="dark"] .calendars-popup .calendars-ctrl {
background: rgb(17 24 39);
border-color: rgb(55 65 81);
}
.dark .ethiopian-picker-surface .calendars-month-header,
.dark .ethiopian-picker-surface .calendars-month-header select,
.dark .ethiopian-picker-surface .calendars-month-header input,
.dark .ethiopian-picker-surface .calendars-month a,
.dark .ethiopian-picker-surface .calendars-month span,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header select,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month-header input,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month a,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month span,
.dark .calendars-popup .calendars-month-header,
.dark .calendars-popup .calendars-month-header select,
.dark .calendars-popup .calendars-month-header input,
.dark .calendars-popup .calendars-month a,
.dark .calendars-popup .calendars-month span,
[data-theme="dark"] .calendars-popup .calendars-month-header,
[data-theme="dark"] .calendars-popup .calendars-month-header select,
[data-theme="dark"] .calendars-popup .calendars-month-header input,
[data-theme="dark"] .calendars-popup .calendars-month a,
[data-theme="dark"] .calendars-popup .calendars-month span {
color: rgb(229 231 235);
}
.dark .ethiopian-picker-surface .calendars-month th,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month th,
.dark .calendars-popup .calendars-month th,
[data-theme="dark"] .calendars-popup .calendars-month th {
color: rgb(156 163 175);
}
.dark .ethiopian-picker-surface .calendars-month a:hover,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month a:hover,
.dark .calendars-popup .calendars-month a:hover,
[data-theme="dark"] .calendars-popup .calendars-month a:hover {
background: rgb(20 83 45);
color: rgb(187 247 208);
}
.dark .ethiopian-picker-surface .calendars-month td .calendars-today,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-today,
.dark .calendars-popup .calendars-month td .calendars-today,
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-today {
background: rgb(20 83 45);
color: rgb(187 247 208);
}
.dark .ethiopian-picker-surface .calendars-month td .calendars-highlight,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-highlight,
.dark .calendars-popup .calendars-month td .calendars-highlight,
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-highlight {
background: rgb(22 101 52);
}
.dark .ethiopian-picker-surface .calendars-month td .calendars-selected,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-selected,
.dark .calendars-popup .calendars-month td .calendars-selected,
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-selected {
background: rgb(34 197 94);
color: rgb(5 46 22);
}
.dark .ethiopian-picker-surface .calendars-month td .calendars-other-month,
[data-theme="dark"] .ethiopian-picker-surface .calendars-month td .calendars-other-month,
.dark .calendars-popup .calendars-month td .calendars-other-month,
[data-theme="dark"] .calendars-popup .calendars-month td .calendars-other-month {
color: rgb(107 114 128);
}
.dark .ethiopian-picker-surface .calendars a.calendars-disabled,
[data-theme="dark"] .ethiopian-picker-surface .calendars a.calendars-disabled,
.dark .calendars-popup .calendars a.calendars-disabled,
[data-theme="dark"] .calendars-popup .calendars a.calendars-disabled {
color: rgb(75 85 99);
}
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,
.dark .calendars-popup .calendars-nav .calendars-cmd-prev,
.dark .calendars-popup .calendars-nav .calendars-cmd-next,
.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump,
.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prev,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-next,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prevJump,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-nextJump {
background: rgb(21 128 61 / 0.22);
border-color: rgb(34 197 94 / 0.45);
color: rgb(187 247 208);
}
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,
.dark .calendars-popup .calendars-nav .calendars-cmd-prev:hover,
.dark .calendars-popup .calendars-nav .calendars-cmd-next:hover,
.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prev:hover,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-next:hover,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover {
background: rgb(34 197 94);
border-color: rgb(34 197 94);
color: rgb(5 46 22);
}
.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
[data-theme="dark"] .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,
.dark .calendars-popup .calendars-nav .calendars-cmd-today,
[data-theme="dark"] .calendars-popup .calendars-nav .calendars-cmd-today {
color: rgb(187 247 208);
}

View File

@@ -0,0 +1,208 @@
/* Flora styling for jQuery Calendars Picker v2.0.0. */
.calendars {
background-color: #e0f4d7;
color: #000;
border: 1px solid #f90;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-family: Arial,Helvetica,Sans-serif;
font-size: 90%;
}
.calendars-rtl {
direction: rtl;
}
.calendars-popup {
z-index: 1000;
}
.calendars-disable {
position: absolute;
z-index: 100;
background-color: white;
opacity: 0.5;
filter: alpha(opacity=50);
}
.calendars a {
color: #000;
text-decoration: none;
}
.calendars a.calendars-disabled {
color: #888;
cursor: auto;
}
.calendars button {
margin: 0.25em;
padding: 0.125em 0em;
background-color: #fc8;
border: none;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-weight: bold;
}
.calendars-nav, .calendars-ctrl {
float: left;
width: 100%;
background-color: #e0f4d7;
color: #fff;
font-size: 90%;
font-weight: bold;
}
.datepick-ctrl {
background-color: #f90;
}
.calendars-cmd {
width: 30%;
}
.calendars-cmd:hover {
background-color: #b1db87;
}
.calendars-ctrl .calendars-cmd:hover {
background-color: #fa4;
}
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
width: 8%;
}
a.calendars-cmd {
height: 1.5em;
}
button.calendars-cmd {
text-align: center;
}
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
float: left;
padding-left: 2%;
}
.calendars-cmd-current, .calendars-cmd-today {
float: left;
width: 35%;
text-align: center;
}
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
float: right;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
.calendars-rtl .calendars-cmd-clear {
float: right;
padding-left: 0%;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
float: right;
}
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
.calendars-rtl .calendars-cmd-close {
float: left;
padding-left: 2%;
padding-right: 0%;
text-align: left;
}
.calendars-month-nav {
float: left;
background-color: #b1db87;
text-align: center;
}
.calendars-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.calendars-month-nav span {
color: #888;
}
.calendars-month-row {
clear: left;
}
.calendars-month {
float: left;
width: 15em;
border: 1px solid #83c948;
text-align: center;
}
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
height: 1.5em;
background-color: #83c948;
color: #fff;
font-weight: bold;
}
.calendars-month-header select, .calendars-month-header input {
height: 1.4em;
border: none;
}
.calendars-month-header input {
position: absolute;
display: none;
}
.calendars-month table {
width: 100%;
border-collapse: collapse;
}
.calendars-month thead {
border-bottom: 1px solid #aaa;
}
.calendars-month th, .calendars-month td {
margin: 0em;
padding: 0em;
font-weight: normal;
text-align: center;
}
.calendars-month th {
border: 1px solid #b1db87;
}
.calendars-month th, .calendars-month th a {
background-color: #b1db87;
color: #000;
border: 1px solid #b1db87;
}
.calendars-month td {
background-color: #fff;
color: #666;
border: 1px solid #b1db87;
}
.calendars-month td.calendars-week * {
background-color: #b1db87;
color: #666;
border: none;
}
.calendars-month a {
display: block;
width: 100%;
padding: 0.125em 0em;
text-decoration: none;
}
.calendars-month span {
display: block;
width: 100%;
padding: 0.125em 0em;
}
.calendars-month td span {
color: #888;
}
.calendars-month td .calendars-other-month {
background-color: #e0f4d7;
}
.calendars-month td .calendars-weekend {
background-color: #e0f4d7;
}
.calendars-month td .calendars-today {
background-color: #b1db87;
}
.calendars-month td .calendars-highlight {
background-color: #fc8;
}
.calendars-month td .calendars-selected {
background-color: #f90;
color: #fff;
}
.calendars-status {
clear: both;
background-color: #b1db87;
text-align: center;
}
.calendars-clear-fix {
clear: both;
}

View File

@@ -0,0 +1,197 @@
/* Humanity styling for jQuery Calendars Picker v2.0.0. */
.calendars {
background-color: #f4f0ec;
color: #1e1b1c;
border: 1px solid #cb842e;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-family: Arial,Helvetica,Sans-serif;
font-size: 90%;
}
.calendars-rtl {
direction: rtl;
}
.calendars-popup {
z-index: 1000;
}
.calendars-disable {
position: absolute;
z-index: 100;
background-color: white;
opacity: 0.5;
filter: alpha(opacity=50);
}
.calendars a {
color: #1e1b1c;
text-decoration: none;
}
.calendars a.calendars-disabled {
color: #888;
cursor: auto;
}
.calendars button {
margin: 0.25em;
padding: 0.125em 0em;
background-color: #ede4d4;
border: none;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-weight: bold;
}
.calendars-nav, .calendars-ctrl {
float: left;
width: 100%;
background-color: #ede4d4;
font-size: 90%;
font-weight: bold;
}
.calendars-ctrl {
background-color: #cb842e;
}
.calendars-cmd {
width: 30%;
}
.calendars-cmd:hover {
background-color: #f4f0ec;
}
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
width: 8%;
}
a.calendars-cmd {
height: 1.5em;
}
button.calendars-cmd {
text-align: center;
}
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
float: left;
padding-left: 2%;
}
.calendars-cmd-current, .calendars-cmd-today {
float: left;
width: 35%;
text-align: center;
}
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
float: right;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
.calendars-rtl .calendars-cmd-clear {
float: right;
padding-left: 0%;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
float: right;
}
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
.calendars-rtl .calendars-cmd-close {
float: left;
padding-left: 2%;
padding-right: 0%;
text-align: left;
}
.calendars-month-nav {
float: left;
text-align: center;
}
.calendars-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.calendars-month-nav span {
color: #888;
}
.calendars-month-row {
clear: left;
}
.calendars-month {
float: left;
width: 17em;
border: 1px solid #e0cfc2;
text-align: center;
}
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
height: 1.5em;
background-color: #cb842e;
color: #fff;
font-weight: bold;
}
.calendars-month-header select, .calendars-month-header input {
height: 1.4em;
border: none;
}
.calendars-month-header input {
position: absolute;
display: none;
}
.calendars-month table {
width: 100%;
border: 2px solid transparent;
border-collapse: collapse;
}
.calendars-month th, .calendars-month td {
margin: 0em;
padding: 0.125em;
font-weight: normal;
text-align: center;
}
.calendars-month td.calendars-week,
.calendars-month td.calendars-week * {
background-color: #cb842e;
color: #fff;
border: 1px solid #cb842e;
}
.calendars-month a {
display: block;
width: 100%;
padding: 0.125em 0em;
background-color: #ede4d4;
color: #000;
border: 1px solid #cdc3b7;
text-decoration: none;
}
.calendars-month span {
display: block;
margin-top: 0.25em;
}
.calendars-month a {
background-color: #ede4d4;
color: #444;
border: 1px solid #cdc3b7;
text-decoration: none;
}
.calendars-month td span {
color: #888;
}
.calendars-month td .calendars-other-month {
background-color: #f4f0ec;
}
.calendars-month td .calendars-today {
background-color: #f5f5b5;
border: 1px solid #d9bb73;
}
.calendars-month td .calendars-highlight {
background-color: #f5f0e5;
color: #1e1b1c;
border: 1px solid #f5ad66;
}
.calendars-month td .calendars-selected {
background-color: #cb842e;
color: #fff;
border: 1px solid #cb842e;
}
.calendars-status {
clear: both;
text-align: center;
}
.calendars-clear-fix {
clear: both;
}

View File

@@ -0,0 +1,215 @@
/* Default styling for jQuery Calendars Picker v2.0.0. */
.calendars {
background-color: #fff;
color: #000;
border: 1px solid #444;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-family: Arial,Helvetica,Sans-serif;
font-size: 90%;
}
.calendars-rtl {
direction: rtl;
}
.calendars-popup {
z-index: 1000;
}
.calendars-disable {
position: absolute;
z-index: 100;
background-color: white;
opacity: 0.5;
filter: alpha(opacity=50);
}
.calendars a {
color: #fff;
text-decoration: none;
}
.calendars a.calendars-disabled {
color: #888;
cursor: auto;
}
.calendars button {
margin: 0.25em;
padding: 0.125em 0em;
background-color: #fcc;
border: none;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-weight: bold;
}
.calendars-nav, .calendars-ctrl {
float: left;
width: 100%;
background-color: #000;
color: #fff;
font-size: 90%;
font-weight: bold;
}
.calendars-ctrl {
background-color: #600;
}
.calendars-cmd {
width: 30%;
}
.calendars-cmd:hover {
background-color: #777;
}
.calendars-ctrl .calendars-cmd:hover {
background-color: #f08080;
}
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
width: 8%;
}
a.calendars-cmd {
height: 1.5em;
}
button.calendars-cmd {
text-align: center;
}
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
float: left;
padding-left: 2%;
}
.calendars-cmd-current, .calendars-cmd-today {
float: left;
width: 35%;
text-align: center;
}
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
float: right;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
.calendars-rtl .calendars-cmd-clear {
float: right;
padding-left: 0%;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
float: right;
}
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
.calendars-rtl .calendars-cmd-close {
float: left;
padding-left: 2%;
padding-right: 0%;
text-align: left;
}
.calendars-month-nav {
float: left;
background-color: #777;
text-align: center;
}
.calendars-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.calendars-month-nav span {
color: #888;
}
.calendars-month-row {
clear: left;
}
.calendars-month {
float: left;
width: 15em;
border: 1px solid #444;
text-align: center;
}
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
height: 1.5em;
background-color: #444;
color: #fff;
font-weight: bold;
}
.calendars-month-header select, .calendars-month-header input {
height: 1.4em;
border: none;
}
.calendars-month-header input {
position: absolute;
display: none;
}
.calendars-month table {
width: 100%;
border-collapse: collapse;
}
.calendars-month thead {
border-bottom: 1px solid #aaa;
}
.calendars-month th, .calendars-month td {
margin: 0em;
padding: 0em;
font-weight: normal;
text-align: center;
}
.calendars-month th {
border: 1px solid #777;
}
.calendars-month th, .calendars-month th a {
background-color: #777;
color: #fff;
}
.calendars-month td {
background-color: #eee;
border: 1px solid #aaa;
}
.calendars-month td.calendars-week {
border: 1px solid #777;
}
.calendars-month td.calendars-week * {
background-color: #777;
color: #fff;
border: none;
}
.calendars-month a {
display: block;
width: 100%;
padding: 0.125em 0em;
background-color: #eee;
color: #000;
text-decoration: none;
}
.calendars-month span {
display: block;
width: 100%;
padding: 0.125em 0em;
}
.calendars-month td span {
color: #888;
}
.calendars-month td .calendars-other-month {
background-color: #fff;
}
.calendars-month td .calendars-weekend {
background-color: #ddd;
}
.calendars-month td .calendars-today {
background-color: #f0c0c0;
}
.calendars-month td .calendars-highlight {
background-color: #f08080;
}
.calendars-month td .calendars-selected {
background-color: #777;
color: #fff;
}
.calendars-month th.calendars-week {
background-color: #777;
color: #fff;
}
.calendars-status {
clear: both;
background-color: #ddd;
text-align: center;
}
.calendars-clear-fix {
clear: both;
}

View File

@@ -0,0 +1,199 @@
/* Redmond styling for jQuery Calendars Picker v2.0.0. */
.calendars {
background-color: #fff;
color: #222;
border: 1px solid #4297d7;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-family: Arial,Helvetica,Sans-serif;
font-size: 90%;
}
.calendars-rtl {
direction: rtl;
}
.calendars-popup {
z-index: 1000;
}
.calendars-disable {
position: absolute;
z-index: 100;
background-color: white;
opacity: 0.5;
filter: alpha(opacity=50);
}
.calendars a {
color: #222;
text-decoration: none;
}
.calendars a.calendars-disabled {
color: #888;
cursor: auto;
}
.calendars button {
margin: 0.25em;
padding: 0.125em 0em;
background-color: #5c9ccc;
color: #fff;
border: none;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-weight: bold;
}
.calendars-nav, .calendars-ctrl {
float: left;
width: 100%;
background-color: #fff;
font-size: 90%;
font-weight: bold;
}
.calendars-ctrl {
background-color: #d0e5f5;
}
.calendars-cmd {
width: 30%;
}
.calendars-cmd:hover {
background-color: #dfeffc;
}
button.calendars-cmd:hover {
background-color: #79b7e7;
}
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
width: 8%;
}
a.calendars-cmd {
height: 1.5em;
}
button.calendars-cmd {
text-align: center;
}
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
float: left;
padding-left: 2%;
}
.calendars-cmd-current, .calendars-cmd-today {
float: left;
width: 35%;
text-align: center;
}
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
float: right;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
.calendars-rtl .calendars-cmd-clear {
float: right;
padding-left: 0%;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
float: right;
}
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
.calendars-rtl .calendars-cmd-close {
float: left;
padding-left: 2%;
padding-right: 0%;
text-align: left;
}
.calendars-month-nav {
float: left;
text-align: center;
}
.calendars-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.calendars-month-nav span {
color: #888;
}
.calendars-month-row {
clear: left;
}
.calendars-month {
float: left;
width: 15em;
border: 1px solid #5c9ccc;
text-align: center;
}
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
height: 1.5em;
background-color: #5c9ccc;
color: #fff;
font-weight: bold;
}
.calendars-month-header select, .calendars-month-header input {
height: 1.4em;
border: none;
}
.calendars-month-header input {
position: absolute;
display: none;
}
.calendars-month table {
width: 100%;
border-collapse: collapse;
}
.calendars-month thead {
border-bottom: 1px solid #aaa;
}
.calendars-month th, .calendars-month td {
margin: 0em;
padding: 0em;
font-weight: normal;
text-align: center;
}
.calendars-month th {
border: 1px solid #fff;
border-bottom: 1px solid #c5dbec;
}
.calendars-month td {
border: 1px solid #c5dbec;
}
.calendars-month td.calendars-week * {
background-color: #d0e5f5;
color: #222;
border: none;
}
.calendars-month a {
display: block;
width: 100%;
padding: 0.125em 0em;
background-color: #dfeffc;
color: #000;
text-decoration: none;
}
.calendars-month span {
display: block;
width: 100%;
padding: 0.125em 0em;
}
.calendars-month td span {
color: #888;
}
.calendars-month td .calendars-other-month {
background-color: #fff;
}
.calendars-month td .calendars-today {
background-color: #fad42e;
}
.calendars-month td .calendars-highlight {
background-color: #79b7e7;
}
.calendars-month td .calendars-selected {
background-color: #4297d7;
color: #fff;
}
.calendars-status {
clear: both;
text-align: center;
}
.calendars-clear-fix {
clear: both;
}

View File

@@ -0,0 +1,200 @@
/* Smoothness styling for jQuery Calendars Picker v2.0.0. */
.calendars {
background-color: #fff;
color: #222;
border: 1px solid #aaa;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-family: Arial,Helvetica,Sans-serif;
font-size: 90%;
}
.calendars-rtl {
direction: rtl;
}
.calendars-popup {
z-index: 1000;
}
.calendars-disable {
position: absolute;
z-index: 100;
background-color: white;
opacity: 0.5;
filter: alpha(opacity=50);
}
.calendars a {
color: #222;
text-decoration: none;
}
.calendars a.calendars-disabled {
color: #888;
cursor: auto;
}
.calendars button {
margin: 0.25em;
padding: 0.125em 0em;
background-color: #fcc;
border: none;
-moz-border-radius: 0.25em;
-webkit-border-radius: 0.25em;
border-radius: 0.25em;
font-weight: bold;
}
.calendars-nav, .calendars-ctrl {
float: left;
width: 100%;
background-color: #fff;
font-size: 90%;
font-weight: bold;
}
.calendars-ctrl {
background-color: #fee6e3;
}
.calendars-cmd {
width: 30%;
}
.calendars-cmd:hover {
background-color: #e0e0e0;
}
.calendars-ctrl .calendars-cmd:hover {
background-color: #f08080;
}
.calendars-cmd-prevJump, .calendars-cmd-nextJump {
width: 8%;
}
a.calendars-cmd {
height: 1.5em;
}
button.calendars-cmd {
text-align: center;
}
.calendars-cmd-prev, .calendars-cmd-prevJump, .calendars-cmd-clear {
float: left;
padding-left: 2%;
}
.calendars-cmd-current, .calendars-cmd-today {
float: left;
width: 35%;
text-align: center;
}
.calendars-cmd-next, .calendars-cmd-nextJump, .calendars-cmd-close {
float: right;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-prev, .calendars-rtl .calendars-cmd-prevJump,
.calendars-rtl .calendars-cmd-clear {
float: right;
padding-left: 0%;
padding-right: 2%;
text-align: right;
}
.calendars-rtl .calendars-cmd-current, .calendars-rtl .calendars-cmd-today {
float: right;
}
.calendars-rtl .calendars-cmd-next, .calendars-rtl .calendars-cmd-nextJump,
.calendars-rtl .calendars-cmd-close {
float: left;
padding-left: 2%;
padding-right: 0%;
text-align: left;
}
.calendars-month-nav {
float: left;
text-align: center;
}
.calendars-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.calendars-month-nav span {
color: #888;
}
.calendars-month-row {
clear: left;
}
.calendars-month {
float: left;
width: 15em;
border: 1px solid #aaa;
text-align: center;
}
.calendars-month-header, .calendars-month-header select, .calendars-month-header input {
height: 1.5em;
background-color: #e0e0e0;
color: #222;
font-weight: bold;
}
.calendars-month-header select, .calendars-month-header input {
height: 1.4em;
border: none;
}
.calendars-month-header input {
position: absolute;
display: none;
}
.calendars-month table {
width: 100%;
border-collapse: collapse;
}
.calendars-month thead {
border-bottom: 1px solid #aaa;
}
.calendars-month th, .calendars-month td {
margin: 0em;
padding: 0em;
font-weight: normal;
text-align: center;
}
.calendars-month thead tr {
border: 1px solid #aaa;
}
.calendars-month td {
background-color: #eee;
border: 1px solid #aaa;
}
.calendars-month td.calendars-week * {
background-color: #e0e0e0;
color: #222;
border: none;
}
.calendars-month a {
display: block;
width: 100%;
padding: 0.125em 0em;
background-color: #eee;
color: #000;
text-decoration: none;
}
.calendars-month span {
display: block;
width: 100%;
padding: 0.125em 0em;
}
.calendars-month td span {
color: #888;
}
.calendars-month td .calendars-other-month {
background-color: #fff;
}
.calendars-month td .calendars-weekend {
background-color: #ddd;
}
.calendars-month td .calendars-today {
background-color: #fbf9ee;
}
.calendars-month td .calendars-highlight {
background-color: #dadada;
}
.calendars-month td .calendars-selected {
background-color: #fcc;
}
.calendars-status {
clear: both;
text-align: center;
}
.calendars-clear-fix {
clear: both;
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Blacktie override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #eeeeee; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #1c1c1c; /* Set (.ui-state-hover) colours from theme here */
color: #ffffff;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #333333; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Blitzer override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #f6f6f6; /* Set (.ui-state-hover) colours from theme here */
color: #111111;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #cc0000; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #555555; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Cupertino override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #222222; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #f0f0f0; /* Set (.ui-state-hover) colours from theme here */
color: #0b5b98;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #e7eef3; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Dark Hive override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #003147; /* Set (.ui-state-hover) colours from theme here */
color: #ffffff;;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #444444; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #eeeeee; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller DotLuv override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #f6f6f6; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #00498f; /* Set (.ui-state-hover) colours from theme here */
color: #ffffff;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #0b3e6f; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #ffffff; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Eggplant override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #ffffff; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #eae6ea; /* Set (.ui-state-hover) colours from theme here */
color: #734d99;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #30273a; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #fafafa; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller ExciteBike override style sheet for jQuery dCalendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #e69700; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #2293f7; /* Set (.ui-state-hover) colours from theme here */
color: #ffffff;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #f9f9f9; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #333333; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller Flick override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #444444; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #0073ea; /* Set (.ui-state-hover) colours from theme here */
color: #ffffff;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #dddddd; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #444444; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,18 @@
/* ThemeRoller HotSneaks override style sheet for jQuery Calendars Picker v2.0.0. */
@import "ui.calendars.picker.css";
.ui-widget-header a,
.ui-widget-header select {
color: #e1e463; /* Set (.ui-widget-header a) colour from theme here */
}
.ui-widget-header a:hover {
background-color: #ccd232; /* Set (.ui-state-hover) colours from theme here */
color: #212121;
}
.ui-widget-header select,
.ui-widget-header option {
background-color: #35414f; /* Set (.ui-widget-header) background colour from theme here */
}
.ui-state-highlight a {
color: #363636; /* Set (.ui-state-highlight) colour from theme here */
}

View File

@@ -0,0 +1,107 @@
/* ThemeRoller override style sheet for jQuery Calendars Picker v2.0.0. */
.ui-datepicker {
display: block;
}
#ui-datepicker-div,
.ui-datepicker-inline {
width: 17em;
font-size: 75%;
}
#ui-datepicker-div {
z-index: 100;
}
.ui-datepicker-inline {
float: left;
}
.ui-datepicker-rtl {
direction: rtl;
}
#ui-datepicker-div a,
.ui-datepicker-inline a {
text-decoration: none;
}
.ui-datepicker-prompt {
height: 1.5em;
padding-top: 0.25em;
text-align: center;
}
button.ui-datepicker-cmd {
height: 2em;
}
.ui-datepicker-cmd-clear {
float: left;
margin-left: 0.25em;
}
.ui-datepicker-cmd-close {
float: right;
margin-right: 0.25em;
}
.ui-datepicker-cmd-prev {
position: static;
float: left;
width: 30%;
height: auto;
margin-left: 1%;
}
.ui-datepicker-cmd-next {
position: static;
float: right;
width: 30%;
height: auto;
margin-right: 1%;
text-align: right;
}
.ui-datepicker-cmd-current,
.ui-datepicker-cmd-today {
float: left;
width: 37%;
text-align: center;
}
.ui-datepicker-month-nav {
float: left;
text-align: center;
}
.ui-datepicker-month-nav div {
float: left;
width: 12.5%;
margin: 1%;
padding: 1%;
}
.ui-datepicker-month-nav span {
color: #888;
}
.ui-datepicker-row-break {
width: 100%;
font-size: 100%;
}
.ui-datepicker-group {
float: left;
width: 17em;
}
.ui-datepicker-group .ui-datepicker-header {
height: 1.5em;
text-align: center;
}
.ui-datepicker select,
.ui-datepicker-inline select {
width: auto;
height: 1.66em;
border: none;
font-weight: bold;
}
.ui-datepicker th {
padding: 0.5em 0.3em;
}
.ui-datepicker td,
.ui-datepicker td a,
.ui-datepicker td span {
border: 1px solid transparent;
text-align: center;
}
.ui-datepicker-status {
padding: 0.25em 0em;
text-align: center;
}
.ui-datepicker .ui-helper-clearfix {
clear: both;
}

View File

@@ -0,0 +1,457 @@
import $ from "@/shared/common/jquery-global";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/shared/lib/utils";
// CSS and plugins
import "@/shared/common/css/redmond.calendars.picker.css";
import "@/shared/common/css/ethiopian-date-picker-overrides.css";
import "@/shared/common/js/jquery.plugin.js";
import "@/shared/common/js/jquery.calendars.js";
import "@/shared/common/js/jquery.calendars.plus.js";
import "@/shared/common/js/jquery.calendars.picker.js";
import "@/shared/common/js/jquery.calendars.ethiopian.js";
import "@/shared/common/js/jquery.calendars.ethiopian-am.js";
interface CalendarsDate {
toString(): string;
}
interface CalendarsDateFactory {
toJSDate(): Date;
}
interface CalendarsInstance {
newDate: (year: number, month: number, day: number) => CalendarsDateFactory;
fromJSDate: (jsDate: Date) => unknown;
formatDate: (
format: string,
date: unknown,
settings?: { localNumbers?: boolean },
) => string;
}
interface CalendarsApi {
instance: (calendarType: string, language: string) => CalendarsInstance;
}
interface CalendarsPickerOptions {
calendar: CalendarsInstance;
dateFormat: string;
onSelect: (dates: CalendarsDate[]) => void;
}
type CalendarsPickerElement = JQuery<HTMLElement> & {
calendarsPicker: (...args: unknown[]) => unknown;
};
declare global {
interface JQueryStatic {
calendars: CalendarsApi;
}
}
interface EthiopianDatePickerModalProps {
value?: string | Date;
onChange: (value: string) => void;
}
const ETHIOPIAN_UTC_OFFSET_HOURS = 3;
const toEthiopianTimeInput = (date: Date): string => {
if (!(date instanceof Date) || isNaN(date.getTime())) {
return "00:00";
}
const totalMinutesUtc = date.getUTCHours() * 60 + date.getUTCMinutes();
const totalMinutesEthiopian =
(totalMinutesUtc + ETHIOPIAN_UTC_OFFSET_HOURS * 60 + 1440) % 1440;
const hh = Math.floor(totalMinutesEthiopian / 60)
.toString()
.padStart(2, "0");
const mm = (totalMinutesEthiopian % 60).toString().padStart(2, "0");
return `${hh}:${mm}`;
};
const getCurrentEthiopianTimeInput = (): string => {
return toEthiopianTimeInput(new Date());
};
/**
* Ethiopian 12-hour clock parts. The Ethiopian day starts at 6:00 EAT, so EAT
* 6:00 AM is clock 12 (day) and EAT 6:00 PM is clock 12 (night).
*
* `period` is purely a display concern — the stored value is always derived
* from the EAT 24-hour time, so the backend payload is unaffected.
*/
type EthiopianClockPeriod = "day" | "night";
interface EthiopianClockParts {
hour: number; // 1-12
minute: number; // 0-59
period: EthiopianClockPeriod;
}
const ETHIOPIAN_HOUR_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
// EAT 24h "HH:mm" -> Ethiopian 12h clock parts (day starts at 6:00 EAT).
const eatTimeToEthiopianClock = (eatTime: string): EthiopianClockParts => {
const [hhRaw, mmRaw] = (eatTime || "").split(":");
const parsedHour = Number(hhRaw);
const parsedMinute = Number(mmRaw);
const eatHour = Number.isFinite(parsedHour)
? ((parsedHour % 24) + 24) % 24
: 0;
const minute = Number.isFinite(parsedMinute)
? ((parsedMinute % 60) + 60) % 60
: 0;
const hour = (eatHour + 6) % 12 || 12;
const period: EthiopianClockPeriod =
eatHour >= 6 && eatHour < 18 ? "day" : "night";
return { hour, minute, period };
};
// Ethiopian 12h clock parts -> EAT 24h "HH:mm".
const ethiopianClockToEatTime = ({
hour,
minute,
period,
}: EthiopianClockParts): string => {
const base = (hour % 12) + 6; // day window: 6..17
const eatHour = (period === "night" ? base + 12 : base) % 24;
const hh = eatHour.toString().padStart(2, "0");
const mm = (((minute % 60) + 60) % 60).toString().padStart(2, "0");
return `${hh}:${mm}`;
};
const normalizeDigits = (value: string): string =>
value
.replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 1632))
.replace(/[۰-۹]/g, (digit) => String(digit.charCodeAt(0) - 1776));
const toEthiopianDateInput = (date: Date): string => {
if (
!(date instanceof Date) ||
Number.isNaN(date.getTime()) ||
!$ ||
!$.calendars
) {
return "";
}
try {
const calendar = $.calendars.instance("ethiopian", "am");
const ethiopianDate = calendar.fromJSDate(
new Date(date.getTime() + ETHIOPIAN_UTC_OFFSET_HOURS * 60 * 60 * 1000),
);
return calendar.formatDate("yyyy-mm-dd", ethiopianDate, {
localNumbers: false,
});
} catch {
return "";
}
};
const EthiopianDatePickerModal: React.FC<EthiopianDatePickerModalProps> = ({
value,
onChange,
}) => {
const datePickerRef = useRef<HTMLDivElement>(null);
const pickerElementRef = useRef<CalendarsPickerElement | null>(null);
const isSyncingRef = useRef(false);
const lastSyncedDateRef = useRef<string>("");
const onChangeRef = useRef(onChange);
const initialValueRef = useRef(value);
const [selectedDate, setSelectedDate] = useState<string>("");
const [selectedTime, setSelectedTime] = useState<string>(
getCurrentEthiopianTimeInput,
);
const selectedTimeRef = useRef(selectedTime);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
selectedTimeRef.current = selectedTime;
}, [selectedTime]);
useEffect(() => {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return;
}
const nextDate = toEthiopianDateInput(value);
// selectedTime is stored as EAT 24h "HH:mm"; the Ethiopian clock shown in
// the picker is derived from it via eatTimeToEthiopianClock. Seed it with
// EAT 24h here so the picker (and the trigger placeholder) agree.
const nextTime = toEthiopianTimeInput(value);
setSelectedDate(nextDate);
setSelectedTime(nextTime);
if (
!pickerElementRef.current ||
!$ ||
!$.calendars ||
!nextDate ||
lastSyncedDateRef.current === nextDate
) {
return;
}
try {
const calendar = $.calendars.instance("ethiopian", "am");
const [year, month, day] = nextDate.split("-").map(Number);
if (
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day)
) {
const ethDate = calendar.newDate(year, month, day);
isSyncingRef.current = true;
try {
pickerElementRef.current.calendarsPicker("setDate", ethDate);
lastSyncedDateRef.current = nextDate;
} finally {
isSyncingRef.current = false;
}
}
} catch {
// ignore third-party plugin errors when syncing selection
}
}, [value]);
useEffect(() => {
if (!$ || !$.calendars || !datePickerRef.current) {
return;
}
const pickerHost = datePickerRef.current;
const calendar = $.calendars.instance("ethiopian", "am");
const picker = $(pickerHost) as unknown as CalendarsPickerElement;
pickerElementRef.current = picker;
picker.calendarsPicker({
calendar,
dateFormat: "yyyy-mm-dd",
onSelect: (dates: CalendarsDate[]) => {
if (isSyncingRef.current) return;
if (!dates?.length) return;
const date = dates[0].toString();
lastSyncedDateRef.current = date;
setSelectedDate(date);
onChangeRef.current(`${date} ${selectedTimeRef.current}`);
},
} as CalendarsPickerOptions);
const initialValue = initialValueRef.current;
if (initialValue instanceof Date && !Number.isNaN(initialValue.getTime())) {
try {
const initialEth = toEthiopianDateInput(initialValue);
if (initialEth) {
const [y, m, d] = initialEth.split("-").map(Number);
if (Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d)) {
isSyncingRef.current = true;
try {
picker.calendarsPicker("setDate", calendar.newDate(y, m, d));
lastSyncedDateRef.current = initialEth;
} finally {
isSyncingRef.current = false;
}
}
}
} catch {
// ignore third-party plugin errors when seeding initial date
}
}
return () => {
try {
picker.calendarsPicker("destroy");
} catch {
// ignore cleanup errors from third-party plugin
}
pickerElementRef.current = null;
lastSyncedDateRef.current = "";
};
}, []);
// selectedTime is always EAT 24h "HH:mm"; the Ethiopian clock is a display-only
// projection so the stored value (and backend payload) stays unchanged.
const ethiopianClock = eatTimeToEthiopianClock(selectedTime);
const applyEthiopianClock = (next: EthiopianClockParts) => {
const eatTime = ethiopianClockToEatTime(next);
setSelectedTime(eatTime);
if (selectedDate) {
onChange(`${selectedDate} ${eatTime}`);
}
};
const handleHourChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const hour = Number(e.target.value);
applyEthiopianClock({
...ethiopianClock,
hour: Number.isFinite(hour) ? hour : ethiopianClock.hour,
});
};
const handleMinuteChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = Number(e.target.value);
const minute = Number.isFinite(parsed)
? Math.max(0, Math.min(59, Math.trunc(parsed)))
: 0;
applyEthiopianClock({ ...ethiopianClock, minute });
};
const handlePeriodChange = (period: EthiopianClockPeriod) => {
applyEthiopianClock({ ...ethiopianClock, period });
};
return (
<div className="ethiopian-picker-surface w-full space-y-3">
<div
ref={datePickerRef}
className="ethiopian-picker-calendar w-full overflow-hidden rounded-md border border-gray-200 dark:border-gray-700"
/>
<div className="space-y-1">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
</p>
<div className="flex items-center gap-2">
<select
aria-label="ሰዓት"
className="h-10 flex-1 rounded-md border border-gray-300 bg-white px-2 text-sm text-gray-800 shadow-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
value={ethiopianClock.hour}
onChange={handleHourChange}>
{ETHIOPIAN_HOUR_OPTIONS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
:
</span>
<input
type="number"
min={0}
max={59}
aria-label="ደቂቃ"
className="h-10 w-16 rounded-md border border-gray-300 bg-white px-2 text-center text-sm text-gray-800 shadow-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
value={ethiopianClock.minute.toString().padStart(2, "0")}
onChange={handleMinuteChange}
/>
<div className="flex overflow-hidden rounded-md border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => handlePeriodChange("day")}
className={cn(
"px-3 py-2 text-sm transition-colors",
ethiopianClock.period === "day"
? "bg-primary text-white"
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",
)}>
</button>
<button
type="button"
onClick={() => handlePeriodChange("night")}
className={cn(
"px-3 py-2 text-sm transition-colors",
ethiopianClock.period === "night"
? "bg-primary text-white"
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",
)}>
</button>
</div>
</div>
</div>
</div>
);
};
export function convertEthiopianToGregorian(ethiopianDateTime: string): Date {
if (!ethiopianDateTime || typeof ethiopianDateTime !== "string") {
return new Date(NaN);
}
const normalizedDateTime = normalizeDigits(ethiopianDateTime.trim());
const [datePart, timePart = "00:00"] = normalizedDateTime.split(" ");
if (!datePart || !datePart.match(/^\d{4}-\d{2}-\d{2}$/)) {
return new Date(NaN);
}
const cal = $.calendars.instance("ethiopian", "am");
const [year, month, day] = datePart.split("-").map(Number);
if (isNaN(year) || isNaN(month) || isNaN(day)) {
return new Date(NaN);
}
try {
const ethDate = cal.newDate(year, month, day);
const gregDate = ethDate.toJSDate();
const [hours, minutes] = timePart.split(":").map(Number);
if (
isNaN(hours) ||
isNaN(minutes) ||
hours < 0 ||
hours > 23 ||
minutes < 0 ||
minutes > 59
) {
return new Date(NaN);
}
const utcDate = new Date(
Date.UTC(
gregDate.getFullYear(),
gregDate.getMonth(),
gregDate.getDate(),
hours - ETHIOPIAN_UTC_OFFSET_HOURS,
minutes,
0,
0,
),
);
return utcDate;
} catch (error) {
console.error(
"[convertEthiopianToGregorian] Error during conversion:",
error,
);
return new Date(NaN);
}
}
export function formatEthiopianDate(ethiopianDate: string | Date): string {
if (!ethiopianDate) return "";
if (ethiopianDate instanceof Date) {
const day = ethiopianDate.getDate().toString().padStart(2, "0");
const month = (ethiopianDate.getMonth() + 1).toString().padStart(2, "0");
const year = ethiopianDate.getFullYear();
return `${day}/${month}/${year}`;
}
const parts = ethiopianDate.split("-");
if (parts.length !== 3) return ethiopianDate;
const [year, month, day] = parts;
return `${day.padStart(2, "0")}/${month.padStart(2, "0")}/${year}`;
}
export default EthiopianDatePickerModal;

View File

@@ -0,0 +1,233 @@
import { useState } from "react";
import { Control, FieldValues } from "react-hook-form";
import $ from "@/shared/common/jquery-global";
import { FormField, FormItem, FormLabel, FormMessage } from "../../ui/form";
import EthiopianDatePickerModal, {
convertEthiopianToGregorian,
} from "./AmharicDatePicker";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import { Button } from "../../ui/button";
import { cn } from "@/shared/lib/utils";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "../../ui/calendar";
import { Input } from "../../ui/input";
import { useTranslation } from "react-i18next";
import {
formatEthiopianClockTime,
utcToEthiopian,
} from "@/shared/common/utils/ethiopianTimeZone";
interface DateFieldProps {
name: "startDate" | "endDate";
label: string;
control: Control<FieldValues>;
isEthiopian: boolean;
minDate?: Date | null;
}
interface EthiopianCalendarFormatter {
fromJSDate: (jsDate: Date) => unknown;
formatDate: (
format: string,
date: unknown,
settings?: { localNumbers?: boolean },
) => string;
}
const DateField: React.FC<DateFieldProps> = ({
name,
label,
control,
isEthiopian,
minDate,
}) => {
const [gregorianTimeFallback, setGregorianTimeFallback] = useState<string>(
() => format(new Date(), "HH:mm"),
);
const { t } = useTranslation();
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const getGregorianTimeValue = (): string => {
if (
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
) {
return format(field.value, "HH:mm");
}
return gregorianTimeFallback;
};
const handleEthiopianChange = (ethiopianDate: string) => {
const gregorianDate = convertEthiopianToGregorian(ethiopianDate);
if (!Number.isNaN(gregorianDate.getTime())) {
field.onChange(gregorianDate);
}
};
const getDisplayText = (): string => {
if (isEthiopian) {
if (
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
) {
const ethiopianTime = utcToEthiopian(field.value);
const timeStr = formatEthiopianClockTime(field.value);
try {
const ethiopianCalendar = $.calendars.instance(
"ethiopian",
"am",
) as unknown as EthiopianCalendarFormatter;
const ethiopianDate =
ethiopianCalendar.fromJSDate(ethiopianTime);
const dateStr = ethiopianCalendar.formatDate(
"yyyy-mm-dd",
ethiopianDate,
{ localNumbers: true },
);
return `${dateStr} ${timeStr}`;
} catch {
const fallbackDate = format(ethiopianTime, "yyyy-MM-dd");
return `${fallbackDate} ${timeStr}`;
}
}
return t("delegation.pickDate");
}
if (
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
) {
const dateText = format(field.value, "PPP");
const timeText = format(field.value, "HH:mm");
return `${dateText} ${timeText}`;
}
return t("delegation.pickDate");
};
return (
<FormItem className="flex flex-col gap-2">
<FormLabel className="text-base font-medium text-gray-700 dark:text-gray-200">
{label}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"group h-11 w-full justify-between rounded-md border border-gray-300 bg-white px-3 text-left text-base font-normal shadow-sm transition-colors hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-primary/30 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-100",
!field.value && "text-gray-500 dark:text-gray-400",
)}>
<span className="truncate">{getDisplayText()}</span>
<CalendarIcon className="ml-3 h-4 w-4 shrink-0 text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-[95vw] overflow-x-auto rounded-lg border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900"
side="bottom"
align="start"
sideOffset={8}
avoidCollisions={false}>
{isEthiopian ? (
<div className="flex min-w-[20rem] flex-col gap-2">
<EthiopianDatePickerModal
value={field.value}
onChange={handleEthiopianChange}
/>
</div>
) : (
<div className="min-w-[20rem] space-y-3">
<Calendar
mode="single"
selected={
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
? field.value
: undefined
}
defaultMonth={
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
? field.value
: minDate instanceof Date &&
!Number.isNaN(minDate.getTime())
? minDate
: undefined
}
onSelect={(selectedDate) => {
if (!selectedDate) {
field.onChange(null);
return;
}
const [hours, minutes] = getGregorianTimeValue()
.split(":")
.map(Number);
const nextDate = new Date(selectedDate);
nextDate.setHours(hours || 0, minutes || 0, 0, 0);
field.onChange(nextDate);
}}
initialFocus
className="w-full p-2"
disabled={(date) => {
const startOfToday = new Date(
new Date().setHours(0, 0, 0, 0),
);
if (date < startOfToday) return true;
if (
minDate instanceof Date &&
!Number.isNaN(minDate.getTime())
) {
const startOfMin = new Date(minDate);
startOfMin.setHours(0, 0, 0, 0);
if (date < startOfMin) return true;
}
return false;
}}
/>
<div className="space-y-1 px-2 pb-1">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
{t("common.Time", "Time")}
</p>
<Input
type="time"
value={getGregorianTimeValue()}
onChange={(event) => {
const newTime = event.target.value;
setGregorianTimeFallback(newTime);
if (
field.value instanceof Date &&
!Number.isNaN(field.value.getTime())
) {
const [hours, minutes] = newTime
.split(":")
.map(Number);
const nextDate = new Date(field.value);
nextDate.setHours(hours || 0, minutes || 0, 0, 0);
field.onChange(nextDate);
}
}}
className="h-10"
/>
</div>
</div>
)}
</PopoverContent>
</Popover>
<FormMessage className="text-sm" />
</FormItem>
);
}}
/>
);
};
export default DateField;

View File

@@ -0,0 +1,242 @@
import { useState } from "react";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../ui/form";
import EthiopianDatePickerModal, {
convertEthiopianToGregorian,
} from "./AmharicDatePicker";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { cn } from "@/shared/lib/utils";
import { format } from "date-fns";
import { CalendarIcon, Clock } from "lucide-react";
import { Calendar } from "../../ui/calendar";
import { useTranslation } from "react-i18next";
import $ from "@/shared/common/jquery-global";
import {
formatEthiopianClockTime,
utcToEthiopian,
} from "@/shared/common/utils/ethiopianTimeZone";
interface DateTimeFieldProps {
name: "startDate" | "endDate";
label: string;
control: any;
isEthiopian: boolean;
isEditing: boolean;
fieldValue?: Date | null;
}
interface EthiopianCalendarFormatter {
fromJSDate: (jsDate: Date) => unknown;
formatDate: (
format: string,
date: unknown,
settings?: { localNumbers?: boolean },
) => string;
}
const DateTimeField: React.FC<DateTimeFieldProps> = ({
name,
label,
control,
isEthiopian,
isEditing,
fieldValue,
}) => {
const [ethiopianDisplay, setEthiopianDisplay] = useState<string | null>("");
const [showTimeInput, setShowTimeInput] = useState(false);
const { t } = useTranslation();
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const handleEthiopianChange = (ethiopianDate: string) => {
setEthiopianDisplay(ethiopianDate);
const gregorianDate = convertEthiopianToGregorian(ethiopianDate);
field.onChange(gregorianDate);
};
const handleTimeChange = (hours: string, minutes: string) => {
if (field.value) {
const date = new Date(field.value);
date.setHours(parseInt(hours) || 0, parseInt(minutes) || 0, 0, 0);
field.onChange(date);
}
};
const getTimeString = () => {
if (!field.value) return "";
const date = new Date(field.value);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
};
const getDateTimeDisplay = () => {
if (!field.value) return t("delegation.pickDate");
const date = new Date(field.value);
if (Number.isNaN(date.getTime())) return t("delegation.pickDate");
const timeStr = isEthiopian
? formatEthiopianClockTime(date)
: getTimeString();
const dateStr = (() => {
if (!isEthiopian) return format(date, "PPP");
if (ethiopianDisplay) return ethiopianDisplay.split(" ")[0];
try {
const ethiopianCalendar = $.calendars.instance(
"ethiopian",
"am",
) as unknown as EthiopianCalendarFormatter;
const ethiopianDate = ethiopianCalendar.fromJSDate(
utcToEthiopian(date),
);
return ethiopianCalendar.formatDate(
"yyyy-mm-dd",
ethiopianDate,
{ localNumbers: true },
);
} catch {
return format(utcToEthiopian(date), "yyyy-MM-dd");
}
})();
return `${dateStr} ${timeStr}`;
};
return (
<FormItem className="flex flex-col gap-2">
<FormLabel className="text-base font-medium text-gray-700">
{label}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between px-3 py-2 text-left font-normal text-base",
!field.value && "text-muted-foreground",
)}
>
{getDateTimeDisplay()}
<CalendarIcon className="ml-2 h-4 w-4 text-gray-500" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-[95vw] overflow-x-auto p-4"
side="bottom"
align="start"
avoidCollisions={false}
>
<div className="min-w-[20rem] space-y-4">
{/* Date Picker */}
{isEthiopian ? (
<div className="flex flex-col gap-2">
<FormControl>
<EthiopianDatePickerModal
value={field.value}
onChange={handleEthiopianChange}
/>
</FormControl>
</div>
) : (
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
disabled={(date) =>
date < new Date(new Date().setHours(0, 0, 0, 0))
}
/>
)}
{/* Time Picker — Ethiopian mode uses the Ethiopian-clock
picker inside EthiopianDatePickerModal above, so this
Gregorian-local time picker is only shown otherwise. */}
{!isEthiopian && field.value && (
<div className="border-t pt-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="h-4 w-4 text-gray-500" />
<label className="text-sm font-medium text-gray-700">
{t("delegation.time") || "Time"}
</label>
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<label className="text-xs text-gray-600 block mb-1">
{t("delegation.hours") || "Hours"}
</label>
<Input
type="number"
min="0"
max="23"
value={String(
new Date(field.value).getHours(),
).padStart(2, "0")}
onChange={(e) =>
handleTimeChange(
e.target.value,
String(
new Date(field.value).getMinutes(),
).padStart(2, "0"),
)
}
className="text-center text-sm"
placeholder="00"
/>
</div>
<div className="flex items-center justify-center pt-6">
:
</div>
<div className="flex-1">
<label className="text-xs text-gray-600 block mb-1">
{t("delegation.minutes") || "Minutes"}
</label>
<Input
type="number"
min="0"
max="59"
value={String(
new Date(field.value).getMinutes(),
).padStart(2, "0")}
onChange={(e) =>
handleTimeChange(
String(
new Date(field.value).getHours(),
).padStart(2, "0"),
e.target.value,
)
}
className="text-center text-sm"
placeholder="00"
/>
</div>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
);
}}
/>
);
};
export default DateTimeField;

View File

@@ -0,0 +1,806 @@
import React, { JSX, useRef, useState } from "react";
import { Trash2, X, ChevronDown, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../ui/select";
import { MultiSelect } from "../../ui/multi-select";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import TreeView, { TreeViewItem } from "@/shared/common/ui/treeSelect";
import { Controller } from "react-hook-form";
import { SingleSelect } from "../../ui/single-select";
import { useLocalizedName } from "../../localizedName";
import i18next from "i18next";
import { getCheckedLeafNodes } from "@/record-management/components/userRecords/common/treeUtils";
type BaseSelectOption = {
label: string;
value: string;
unitId?: string;
};
interface CustomSelectProps {
form: any; // your react-hook-form instance
name: string;
label: string;
placeholder: string;
options: { id: string; name: { en: string; am: string } }[];
localizedName: (name: { en: string; am: string }) => string;
}
// 1⃣ Form Select Field
export const FormSelectField = ({
control,
name,
label,
options,
placeholder,
className,
customLabel,
disabled,
onCustomChange,
}: {
control: any;
name: string;
label: string;
options: BaseSelectOption[];
placeholder?: string;
className?: string;
customLabel?: React.ReactNode;
disabled?: boolean;
onCustomChange?: (value: string) => void;
}) => (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={className || "space-y-1"}>
{customLabel ? (
customLabel
) : (
<FormLabel>
{name === "headerId" ||
name === "footerId" ||
name === "recordType" ||
name === "initiationType" ||
name === "initiatedAs" ? (
<span>
{label} <span className="text-red-500 text-xl">*</span>
</span>
) : (
label
)}
</FormLabel>
)}
<FormControl>
<Select
value={field.value ?? ""}
disabled={disabled}
onValueChange={(value) => {
field.onChange(value);
onCustomChange?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="w-full max-w-full sm:max-w-md z-[60] overflow-x-auto">
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
export const FormSingleSelectField = ({
control,
name,
label,
options,
placeholder = "Select option",
onCustomChange,
}: {
control: any;
name: string;
label: string;
options: BaseSelectOption[];
placeholder?: string;
onCustomChange?: (selectedValue: string) => void;
}) => (
<FormField
control={control}
name={name}
render={({ field }) => {
const handleChange = (value: string) => {
field.onChange(value);
if (onCustomChange) onCustomChange(value);
};
return (
<FormItem className="space-y-1">
<FormLabel>{label}</FormLabel>
<FormControl>
<SingleSelect
options={options}
value={field.value || ""}
onValueChange={handleChange}
placeholder={placeholder}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
// 2⃣ Multi Select Field
export const FormMultiSelectField = ({
control,
name,
label,
options,
placeholder = "Select options",
maxCount,
disabled,
onCustomChange,
}: {
control: any;
name: string;
label: string;
options: BaseSelectOption[];
placeholder?: string;
maxCount?: number;
disabled?: boolean;
onCustomChange?: (selectedValues: string[]) => void;
}) => (
<FormField
control={control}
name={name}
render={({ field }) => {
const handleChange = (values: string[]) => {
field.onChange(values);
if (onCustomChange) onCustomChange(values);
};
const fieldValue = Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: [];
return (
<FormItem className="space-y-1">
<FormLabel>
{name === "toPositions" ||
name === "toEmployees" ||
name === "collaborators" ||
name === "receivingDepartments" ||
name === "assignedEmployees" ? (
<span>
{label} <span className="text-red-500 text-xl">*</span>
</span>
) : (
label
)}
</FormLabel>
<FormControl>
<MultiSelect
options={options}
value={fieldValue} // controlled value
onValueChange={handleChange} // controlled onChange
placeholder={placeholder}
maxCount={maxCount || 5}
disabled={disabled}
className="w-full max-w-full sm:max-w-md overflow-x-auto"
animation={0}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
export const FormTreeSelectField = ({
control,
name,
label,
options,
handleCheckChange,
collapsible = false,
localizedName,
selectedCountResolver,
}: {
control: any;
name: string;
label: string;
options: TreeViewItem[];
handleCheckChange?:
| ((item: TreeViewItem | TreeViewItem[], checked: boolean) => void)
| undefined;
collapsible?: boolean;
localizedName: (
name?:
| {
am: string;
en: string;
}
| undefined,
) => string;
selectedCountResolver?: (value: unknown) => number;
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = React.useState(!collapsible);
const containerRef = React.useRef<HTMLDivElement>(null);
// Close dropdown on outside click
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
if (collapsible) {
return (
<FormField
control={control}
name={name}
rules={{ required: `${label} is required` }}
render={({ field }) => {
const selectedCount = selectedCountResolver
? selectedCountResolver(field.value)
: getCheckedLeafNodes(Array.isArray(field.value) ? field.value : [])
.length;
return (
<FormItem
ref={containerRef}
className="sm:col-span-3 w-full min-w-0 max-w-full"
>
<FormLabel>
{name === "receivingUnitIds" ? (
<span>
{label} <span className="text-red-500 text-xl">*</span>
</span>
) : (
label
)}
</FormLabel>
<div className="relative w-full min-w-0 max-w-full">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="h-auto min-h-10 w-full max-w-full justify-between gap-2 py-2"
>
<span className="block min-w-0 flex-1 truncate text-left">
{selectedCount > 0
? `${selectedCount} unit(s) selected`
: `${t("common.select")} ${label}`}
</span>
<ChevronDown
className={`h-4 w-4 shrink-0 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</Button>
{isOpen && (
<div className="absolute left-0 right-0 z-50 mt-1 w-full min-w-0 max-w-full overflow-hidden rounded-md border bg-background p-2 shadow-md">
<TreeView
embedded
className="max-h-[55vh] overflow-y-auto sm:max-h-[450px]"
data={options}
showExpandAll={false}
showCheckboxes
onCheckChange={handleCheckChange}
localizedName={localizedName}
/>
</div>
)}
</div>
<FormMessage />
</FormItem>
);
}}
/>
);
}
return (
<FormField
control={control}
name={name}
rules={{ required: `${label} is required` }}
render={() => (
<FormItem className="sm:col-span-3 w-full min-w-0">
<FormLabel>{label}</FormLabel>
<TreeView
className="max-h-[55vh] overflow-y-auto sm:max-h-[450px]"
data={options}
showExpandAll={false}
showCheckboxes
onCheckChange={handleCheckChange}
localizedName={localizedName}
/>
<FormMessage />
</FormItem>
)}
/>
);
};
// 3⃣ Addable List Field (string-based)
export const FormAddableList = ({
form,
name,
label,
placeholder = "",
valueAdapter,
}: {
form: any;
name: string;
label: string;
placeholder?: string;
valueAdapter?: {
getDisplayItems: (fieldValue: unknown) => string[];
onItemsChange: (items: string[], fieldValue: unknown) => unknown;
};
}) => {
const { t } = useTranslation();
const [pendingValue, setPendingValue] = useState("");
return (
<FormField
control={form.control}
name={name}
render={({ field }) => {
const items = valueAdapter
? valueAdapter.getDisplayItems(field.value)
: Array.isArray(field.value)
? field.value
: [];
const handleAdd = () => {
const value = pendingValue.trim();
if (value && !items.includes(value)) {
const updatedItems = [...items, value];
field.onChange(
valueAdapter
? valueAdapter.onItemsChange(updatedItems, field.value)
: updatedItems,
);
setPendingValue("");
}
};
const handleDelete = (index: number) => {
const updatedItems = items.filter(
(_: string, i: number) => i !== index,
);
field.onChange(
valueAdapter
? valueAdapter.onItemsChange(updatedItems, field.value)
: updatedItems,
);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
};
return (
<FormItem className="space-y-2">
<FormLabel>{label}</FormLabel>
<div className="flex flex-col sm:flex-row gap-2">
<FormControl className="flex-1">
<Input
placeholder={placeholder}
value={pendingValue}
onChange={(e) => setPendingValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAdd}
/>
</FormControl>
<Button
type="button"
onClick={handleAdd}
className="bg-primary-500 text-white hover:bg-primary-600"
>
{t("addRecord.Add")}
</Button>
</div>
{items.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{items.map((unit: string, index: number) => (
<div
key={`${unit}-${index}`}
className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 border border-blue-100 rounded-md"
>
<span className="text-sm truncate max-w-[200px]">
{unit}
</span>
<X
size={14}
className="cursor-pointer text-blue-700 hover:text-red-600 ml-1"
onClick={() => handleDelete(index)}
/>
</div>
))}
</div>
)}
<FormMessage />
</FormItem>
);
}}
/>
);
};
export const ClearableSelect = ({
form,
name,
label,
placeholder = "",
options,
localizedName,
}: {
form: any;
name: string;
label: string;
placeholder?: string;
options: { id: string; name: any }[];
localizedName: (name: any) => string;
}) => {
return (
<FormItem className="space-y-2">
<FormLabel>{label}</FormLabel>
<FormField
control={form.control}
name={name}
render={({ field }) => (
<>
{field.value ? (
// Selected value with X to clear
<div className="flex items-center justify-between border text-sm rounded-md px-3 py-2 bg-gray-50">
<span>
{localizedName(
options.find((o) => o.id === field.value)?.name,
)}
</span>
<X
size={17}
className="cursor-pointer text-blue-700 hover:text-red-600 ml-1"
onClick={() => field.onChange("")}
/>
</div>
) : (
// Select dropdown
<Select
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent className="w-full max-w-full sm:max-w-md z-[60] overflow-x-auto">
{options?.map((item) => (
<SelectItem key={item.id} value={item.id}>
{localizedName(item.name)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormMessage />
</>
)}
/>
</FormItem>
);
};
export const CustomSelect = ({
form,
name,
label,
placeholder,
options,
localizedName,
}: CustomSelectProps) => {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">{label}</label>
<Controller
control={form.control}
name={name}
render={({ field }) => {
const matchingOption = options.find((opt) => {
const english = opt.name?.en ?? "";
const amharic = opt.name?.am ?? "";
return (
field.value === opt.id ||
field.value === english ||
field.value === amharic
);
});
// Treat undefined, null, and empty string as no selection
const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
const resolvedValue = matchingOption && hasValue
? matchingOption.id
: "";
return (
<Select
value={resolvedValue}
onValueChange={(val) => {
if (val === "__clear__" || val === "") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{localizedName(opt.name)}
</SelectItem>
))}
<SelectItem value="__clear__">{t("common.Cancel")}</SelectItem>
</SelectContent>
</Select>
);
}}
/>
</div>
);
};
export interface TreeViewItemO {
id: string;
name: string | { en: string; am: string }; // allow localized or raw
value?: string;
children?: TreeViewItem[];
}
interface SimpleTreeSelectProps {
label: string;
options: TreeViewItemO[];
value: string | null;
onChange: (id: string | null) => void;
collapsible?: boolean;
localizedName?: (name?: { en?: string; am?: string } | undefined) => string;
className?: string;
}
export const SimpleTreeSelect = ({
label,
options,
value,
onChange,
collapsible = false,
localizedName,
className,
}: SimpleTreeSelectProps) => {
const [isOpen, setIsOpen] = useState(!collapsible);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
const newSet = new Set(expandedItems);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setExpandedItems(newSet);
};
const handleSelect = (id: string) => {
onChange(id);
// Optional: close dropdown after selection
// setIsOpen(false);
};
// Get full path for a selected item
const findSelectedPath = (
items: TreeViewItemO[],
parentPath: string[] = [],
): string | null => {
for (const item of items) {
const name =
typeof item.name === "string"
? item.name
: (localizedName?.(item.name) ?? item.name.en);
if (item.id === value) {
return [...parentPath, name].join(" / ");
}
if (item.children && item.children.length > 0) {
const childPath = findSelectedPath(item.children, [
...parentPath,
name,
]);
if (childPath) return childPath;
}
}
return null;
};
const renderTree = (items: TreeViewItemO[]): JSX.Element[] => {
return items.flatMap((item) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(item.id);
// Determine if any child is selected to highlight parent
const isParentSelected = hasChildren
? item.children!.some((child) => child.id === value)
: false;
// Skip single-unit org
if (hasChildren && item.children!.length === 1) {
return renderTree(item.children!);
}
const itemLabel =
typeof item.name === "string"
? item.name
: (localizedName?.(item.name) ?? item.name.en);
return (
<div key={item.id} className="ml-4 mb-1">
<div className="flex items-center space-x-2">
{hasChildren && (
<button
type="button"
onClick={() => toggleExpand(item.id)}
className="w-4 h-4 flex items-center justify-center"
>
<ChevronDown
className={`h-3 w-3 transition-transform ${
isExpanded ? "rotate-180" : ""
}`}
/>
</button>
)}
<label className="flex items-center space-x-2">
<input
type="radio"
name="unit-select"
checked={value === item.id || isParentSelected}
onChange={() => handleSelect(item.id)}
/>
<span className={isParentSelected ? "font-semibold" : ""}>
{itemLabel}
</span>
</label>
</div>
{hasChildren && isExpanded && (
<div className="ml-4">{renderTree(item.children!)}</div>
)}
</div>
);
});
};
const selectedPathLabel = findSelectedPath(options);
return (
<div className={className ?? "mb-4"}>
<label className="block font-semibold mb-1">{label}</label>
{collapsible && (
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="w-full max-w-lg justify-between mb-2"
>
<span className="truncate">
{selectedPathLabel ?? label}
</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</Button>
)}
{isOpen && (
<div className="border rounded-md p-2 bg-background max-h-96 overflow-y-auto max-w-lg">
{renderTree(options)}
</div>
)}
</div>
);
};
export const SelectedItemsChips = ({
items,
onRemove,
label,
localizedName,
}: {
items: TreeViewItem[];
onRemove: (value: string) => void;
label?: string;
localizedName: (
name?:
| {
am: string;
en: string;
}
| undefined,
) => string;
}) => {
if (!items?.length) return null;
return (
<div className="grid gap-2 sm:col-span-3">
{label && <p className="text-sm font-semibold mb-2">{label}</p>}
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<div
key={item.id}
className="flex items-center bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
<span className="truncate max-w-[180px]">
{localizedName(item.name)}
</span>
<button
type="button"
className="ml-2 text-red-500 hover:text-red-700"
onClick={() => {
onRemove(item.id);
}}
>
×
</button>
</div>
))}
</div>
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,7 @@
import jQuery from "jquery";
// Make jQuery global for legacy plugins
(window as any).jQuery = jQuery;
(window as any).$ = jQuery;
export default jQuery;

View File

@@ -0,0 +1,46 @@
import $ from "jquery";
(function ($: any) {
"use strict";
$.calendars.calendars.ethiopian.prototype.regionalOptions.am = {
name: "የኢትዮጵያ ዘመን አቆጣጠር",
epochs: ["BEE", "EE"],
monthNames: [
"መስከረም",
"ጥቅምት",
"ኅዳር",
"ታህሣሥ",
"ጥር",
"የካቲት",
"መጋቢት",
"ሚያዝያ",
"ግንቦት",
"ሰኔ",
"ሐምሌ",
"ነሐሴ",
"ጳጉሜ",
],
monthNamesShort: [
"መስከ",
"ጥቅም",
"ኅዳር",
"ታህሣ",
"ጥር",
"የካቲ",
"መጋቢ",
"ሚያዝ",
"ግንቦ",
"ሰኔ",
"ሐምሌ",
"ነሐሴ",
"ጳጉሜ",
],
dayNames: ["እሑድ", "ሰኞ", "ማክሰኞ", "ረቡዕ", "ሓሙስ", "ዓርብ", "ቅዳሜ"],
dayNamesShort: ["እሑድ", "ሰኞ", "ማክሰ", "ረቡዕ", "ሓሙስ", "ዓርብ", "ቅዳሜ"],
dayNamesMin: ["እሑ", "ሰኞ", "ማክ", "ረቡ", "ሐሙ", "ዓር", "ቅዳ"],
digits: null,
dateFormat: "dd/mm/yyyy",
firstDay: 0,
isRTL: false,
};
})(jQuery);

View File

@@ -0,0 +1,171 @@
/* http://keith-wood.name/calendars.html
Ethiopian calendar for jQuery v2.1.0.
Written by Keith Wood (wood.keith{at}optusnet.com.au) February 2010.
Available under the MIT (http://keith-wood.name/licence.html) license.
Please attribute the author if you use it. */
(function($) { // Hide scope, no $ conflict
'use strict';
/** Implementation of the Ethiopian calendar.
See <a href="http://en.wikipedia.org/wiki/Ethiopian_calendar">http://en.wikipedia.org/wiki/Ethiopian_calendar</a>.
See also Calendrical Calculations: The Millennium Edition
(<a href="http://emr.cs.iit.edu/home/reingold/calendar-book/index.shtml">http://emr.cs.iit.edu/home/reingold/calendar-book/index.shtml</a>).
@class EthiopianCalendar
@param {string} [language=''] The language code (default English) for localisation. */
function EthiopianCalendar(language) {
this.local = this.regionalOptions[language || ''] || this.regionalOptions[''];
}
EthiopianCalendar.prototype = new $.calendars.baseCalendar();
$.extend(EthiopianCalendar.prototype, {
/** The calendar name.
@memberof EthiopianCalendar */
name: 'Ethiopian',
/** Julian date of start of Ethiopian epoch: 27 August 8 CE (Gregorian).
@memberof EthiopianCalendar */
jdEpoch: 1724220.5,
/** Days per month in a common year.
@memberof EthiopianCalendar */
daysPerMonth: [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 5],
/** <code>true</code> if has a year zero, <code>false</code> if not.
@memberof EthiopianCalendar */
hasYearZero: false,
/** The minimum month number.
@memberof EthiopianCalendar */
minMonth: 1,
/** The first month in the year.
@memberof EthiopianCalendar */
firstMonth: 1,
/** The minimum day number.
@memberof EthiopianCalendar */
minDay: 1,
/** Localisations for the plugin.
Entries are objects indexed by the language code ('' being the default US/English).
Each object has the following attributes.
@memberof EthiopianCalendar
@property {string} name The calendar name.
@property {string[]} epochs The epoch names (before/after year 0).
@property {string[]} monthNames The long names of the months of the year.
@property {string[]} monthNamesShort The short names of the months of the year.
@property {string[]} dayNames The long names of the days of the week.
@property {string[]} dayNamesShort The short names of the days of the week.
@property {string[]} dayNamesMin The minimal names of the days of the week.
@property {string} dateFormat The date format for this calendar.
See the options on <a href="BaseCalendar.html#formatDate"><code>formatDate</code></a> for details.
@property {number} firstDay The number of the first day of the week, starting at 0.
@property {boolean} isRTL <code>true</code> if this localisation reads right-to-left. */
regionalOptions: { // Localisations
'': {
name: 'Ethiopian',
epochs: ['BEE', 'EE'],
monthNames: ['Meskerem', 'Tikemet', 'Hidar', 'Tahesas', 'Tir', 'Yekatit',
'Megabit', 'Miazia', 'Genbot', 'Sene', 'Hamle', 'Nehase', 'Pagume'],
monthNamesShort: ['Mes', 'Tik', 'Hid', 'Tah', 'Tir', 'Yek',
'Meg', 'Mia', 'Gen', 'Sen', 'Ham', 'Neh', 'Pag'],
dayNames: ['Ehud', 'Segno', 'Maksegno', 'Irob', 'Hamus', 'Arb', 'Kidame'],
dayNamesShort: ['Ehu', 'Seg', 'Mak', 'Iro', 'Ham', 'Arb', 'Kid'],
dayNamesMin: ['Eh', 'Se', 'Ma', 'Ir', 'Ha', 'Ar', 'Ki'],
digits: null,
dateFormat: 'dd/mm/yyyy',
firstDay: 0,
isRTL: false
}
},
/** Determine whether this date is in a leap year.
@memberof EthiopianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
@throws Error if an invalid year or a different calendar used. */
leapYear: function(year) {
var date = this._validate(year, this.minMonth, this.minDay, $.calendars.local.invalidYear);
year = date.year() + (date.year() < 0 ? 1 : 0); // No year zero
return year % 4 === 3 || year % 4 === -1;
},
/** Retrieve the number of months in a year.
@memberof EthiopianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {number} The number of months.
@throws Error if an invalid year or a different calendar used. */
monthsInYear: function(year) {
this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
return 13;
},
/** Determine the week of the year for a date.
@memberof EthiopianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if only <code>year</code> specified above).
@param {number} [day] The day to examine (if only <code>year</code> specified above).
@return {number} The week of the year.
@throws Error if an invalid date or a different calendar used. */
weekOfYear: function(year, month, day) {
// Find Sunday of this week starting on Sunday
var checkDate = this.newDate(year, month, day);
checkDate.add(-checkDate.dayOfWeek(), 'd');
return Math.floor((checkDate.dayOfYear() - 1) / 7) + 1;
},
/** Retrieve the number of days in a month.
@memberof EthiopianCalendar
@param {CDate|number} year The date to examine or the year of the month.
@param {number} [month] The month (if only <code>year</code> specified above).
@return {number} The number of days in this month.
@throws Error if an invalid month/year or a different calendar used. */
daysInMonth: function(year, month) {
var date = this._validate(year, month, this.minDay, $.calendars.local.invalidMonth);
return this.daysPerMonth[date.month() - 1] +
(date.month() === 13 && this.leapYear(date.year()) ? 1 : 0);
},
/** Determine whether this date is a week day.
@memberof EthiopianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if only <code>year</code> specified above).
@param {number} [day] The day to examine (if only <code>year</code> specified above).
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
@throws Error if an invalid date or a different calendar used. */
weekDay: function(year, month, day) {
return (this.dayOfWeek(year, month, day) || 7) < 6;
},
/** Retrieve the Julian date equivalent for this date,
i.e. days since January 1, 4713 BCE Greenwich noon.
@memberof EthiopianCalendar
@param {CDate|number} year The date to convert or the year to convert.
@param {number} [month] The month to convert (if only <code>year</code> specified above).
@param {number} [day] The day to convert (if only <code>year</code> specified above).
@return {number} The equivalent Julian date.
@throws Error if an invalid date or a different calendar used. */
toJD: function(year, month, day) {
var date = this._validate(year, month, day, $.calendars.local.invalidDate);
year = date.year();
if (year < 0) { year++; } // No year zero
return date.day() + (date.month() - 1) * 30 +
(year - 1) * 365 + Math.floor(year / 4) + this.jdEpoch - 1;
},
/** Create a new date from a Julian date.
@memberof EthiopianCalendar
@param {number} jd the Julian date to convert.
@return {CDate} the equivalent date. */
fromJD: function(jd) {
var c = Math.floor(jd) + 0.5 - this.jdEpoch;
var year = Math.floor((c - Math.floor((c + 366) / 1461)) / 365) + 1;
if (year <= 0) { year--; } // No year zero
c = Math.floor(jd) + 0.5 - this.newDate(year, 1, 1).toJD();
var month = Math.floor(c / 30) + 1;
var day = c - (month - 1) * 30 + 1;
return this.newDate(year, month, day);
}
});
// Ethiopian calendar implementation
$.calendars.calendars.ethiopian = EthiopianCalendar;
})(jQuery);

View File

@@ -0,0 +1,2 @@
!function(a){"use strict";function b(a){this.local=this.regionalOptions[a||""]||this.regionalOptions[""]}b.prototype=new a.calendars.baseCalendar,a.extend(b.prototype,{name:"Ethiopian",jdEpoch:1724220.5,daysPerMonth:[30,30,30,30,30,30,30,30,30,30,30,30,5],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Ethiopian",epochs:["BEE","EE"],monthNames:["Meskerem","Tikemet","Hidar","Tahesas","Tir","Yekatit","Megabit","Miazia","Genbot","Sene","Hamle","Nehase","Pagume"],monthNamesShort:["Mes","Tik","Hid","Tah","Tir","Yek","Meg","Mia","Gen","Sen","Ham","Neh","Pag"],dayNames:["Ehud","Segno","Maksegno","Irob","Hamus","Arb","Kidame"],dayNamesShort:["Ehu","Seg","Mak","Iro","Ham","Arb","Kid"],dayNamesMin:["Eh","Se","Ma","Ir","Ha","Ar","Ki"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(b){var c=this._validate(b,this.minMonth,this.minDay,a.calendars.local.invalidYear);return b=c.year()+(c.year()<0?1:0),b%4===3||b%4===-1},monthsInYear:function(b){return this._validate(b,this.minMonth,this.minDay,a.calendars.local.invalidYear||a.calendars.regionalOptions[""].invalidYear),13},weekOfYear:function(a,b,c){var d=this.newDate(a,b,c);return d.add(-d.dayOfWeek(),"d"),Math.floor((d.dayOfYear()-1)/7)+1},daysInMonth:function(b,c){var d=this._validate(b,c,this.minDay,a.calendars.local.invalidMonth);return this.daysPerMonth[d.month()-1]+(13===d.month()&&this.leapYear(d.year())?1:0)},weekDay:function(a,b,c){return(this.dayOfWeek(a,b,c)||7)<6},toJD:function(b,c,d){var e=this._validate(b,c,d,a.calendars.local.invalidDate);return b=e.year(),b<0&&b++,e.day()+30*(e.month()-1)+365*(b-1)+Math.floor(b/4)+this.jdEpoch-1},fromJD:function(a){var b=Math.floor(a)+.5-this.jdEpoch,c=Math.floor((b-Math.floor((b+366)/1461))/365)+1;c<=0&&c--,b=Math.floor(a)+.5-this.newDate(c,1,1).toJD();var d=Math.floor(b/30)+1,e=b-30*(d-1)+1;return this.newDate(c,d,e)}}),a.calendars.calendars.ethiopian=b}(jQuery);
//# sourceMappingURL=jquery.calendars.ethiopian.min.map

View File

@@ -0,0 +1 @@
{"version":3,"sources":["..\\..\\src\\js\\jquery.calendars.ethiopian.js"],"names":["$","EthiopianCalendar","language","this","local","regionalOptions","prototype","calendars","baseCalendar","extend","name","jdEpoch","daysPerMonth","hasYearZero","minMonth","firstMonth","minDay","","epochs","monthNames","monthNamesShort","dayNames","dayNamesShort","dayNamesMin","digits","dateFormat","firstDay","isRTL","leapYear","year","date","_validate","invalidYear","monthsInYear","weekOfYear","month","day","checkDate","newDate","add","dayOfWeek","Math","floor","dayOfYear","daysInMonth","invalidMonth","weekDay","toJD","invalidDate","fromJD","jd","c","ethiopian","jQuery"],"mappings":"CAMA,SAAUA,GACT,YAQA,SAASC,GAAkBC,GAC1BC,KAAKC,MAAQD,KAAKE,gBAAgBH,GAAY,KAAOC,KAAKE,gBAAgB,IAG3EJ,EAAkBK,UAAY,GAAIN,GAAEO,UAAUC,aAE9CR,EAAES,OAAOR,EAAkBK,WAG1BI,KAAM,YAGNC,QAAS,UAGTC,cAAe,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAAI,GAG/DC,aAAa,EAGbC,SAAU,EAGVC,WAAY,EAGZC,OAAQ,EAiBRX,iBACCY,IACCP,KAAM,YACNQ,QAAS,MAAO,MAChBC,YAAa,WAAY,UAAW,QAAS,UAAW,MAAO,UAC/D,UAAW,SAAU,SAAU,OAAQ,QAAS,SAAU,UAC1DC,iBAAkB,MAAO,MAAO,MAAO,MAAO,MAAO,MACrD,MAAO,MAAO,MAAO,MAAO,MAAO,MAAO,OAC1CC,UAAW,OAAQ,QAAS,WAAY,OAAQ,QAAS,MAAO,UAChEC,eAAgB,MAAO,MAAO,MAAO,MAAO,MAAO,MAAO,OAC1DC,aAAc,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAClDC,OAAQ,KACRC,WAAY,aACZC,SAAU,EACVC,OAAO,IASTC,SAAU,SAASC,GAClB,GAAIC,GAAO3B,KAAK4B,UAAUF,EAAM1B,KAAKW,SAAUX,KAAKa,OAAQhB,EAAEO,UAAUH,MAAM4B,YAE9E,OADAH,GAAOC,EAAKD,QAAUC,EAAKD,OAAS,EAAI,EAAI,GACrCA,EAAO,IAAM,GAAKA,EAAO,KAAM,GAQvCI,aAAc,SAASJ,GAGtB,MAFA1B,MAAK4B,UAAUF,EAAM1B,KAAKW,SAAUX,KAAKa,OACxChB,EAAEO,UAAUH,MAAM4B,aAAehC,EAAEO,UAAUF,gBAAgB,IAAI2B,aAC3D,IAURE,WAAY,SAASL,EAAMM,EAAOC,GAEjC,GAAIC,GAAYlC,KAAKmC,QAAQT,EAAMM,EAAOC,EAE1C,OADAC,GAAUE,KAAKF,EAAUG,YAAa,KAC/BC,KAAKC,OAAOL,EAAUM,YAAc,GAAK,GAAK,GAStDC,YAAa,SAASf,EAAMM,GAC3B,GAAIL,GAAO3B,KAAK4B,UAAUF,EAAMM,EAAOhC,KAAKa,OAAQhB,EAAEO,UAAUH,MAAMyC,aACtE,OAAO1C,MAAKS,aAAakB,EAAKK,QAAU,IACrB,KAAjBL,EAAKK,SAAkBhC,KAAKyB,SAASE,EAAKD,QAAU,EAAI,IAU3DiB,QAAS,SAASjB,EAAMM,EAAOC,GAC9B,OAAQjC,KAAKqC,UAAUX,EAAMM,EAAOC,IAAQ,GAAK,GAWlDW,KAAM,SAASlB,EAAMM,EAAOC,GAC3B,GAAIN,GAAO3B,KAAK4B,UAAUF,EAAMM,EAAOC,EAAKpC,EAAEO,UAAUH,MAAM4C,YAG9D,OAFAnB,GAAOC,EAAKD,OACRA,EAAO,GAAKA,IACTC,EAAKM,MAA6B,IAApBN,EAAKK,QAAU,GACtB,KAAZN,EAAO,GAAWY,KAAKC,MAAMb,EAAO,GAAK1B,KAAKQ,QAAU,GAO3DsC,OAAQ,SAASC,GAChB,GAAIC,GAAIV,KAAKC,MAAMQ,GAAM,GAAM/C,KAAKQ,QAChCkB,EAAOY,KAAKC,OAAOS,EAAIV,KAAKC,OAAOS,EAAI,KAAO,OAAS,KAAO,CAC9DtB,IAAQ,GAAKA,IACjBsB,EAAIV,KAAKC,MAAMQ,GAAM,GAAM/C,KAAKmC,QAAQT,EAAM,EAAG,GAAGkB,MACpD,IAAIZ,GAAQM,KAAKC,MAAMS,EAAI,IAAM,EAC7Bf,EAAMe,EAAkB,IAAbhB,EAAQ,GAAU,CACjC,OAAOhC,MAAKmC,QAAQT,EAAMM,EAAOC,MAKnCpC,EAAEO,UAAUA,UAAU6C,UAAYnD,GAEhCoD","file":"jquery.calendars.ethiopian.min.js"}

View File

@@ -0,0 +1,982 @@
/* http://keith-wood.name/calendars.html
Calendars for jQuery v2.1.0.
Written by Keith Wood (wood.keith{at}optusnet.com.au) August 2009.
Available under the MIT (http://keith-wood.name/licence.html) license.
Please attribute the author if you use it. */
(function($) { // Hide scope, no $ conflict
'use strict';
function Calendars() {
this.regionalOptions = [];
/** Localised values.
@memberof Calendars
@property {string} [invalidCalendar='Calendar {0} not found']
Error message for an unknown calendar.
@property {string} [invalidDate='Invalid {0} date']
Error message for an invalid date for this calendar.
@property {string} [invalidMonth='Invalid {0} month']
Error message for an invalid month for this calendar.
@property {string} [invalidYear='Invalid {0} year']
Error message for an invalid year for this calendar.
@property {string} [differentCalendars='Cannot mix {0} and {1} dates']
Error message for mixing different calendars. */
this.regionalOptions[''] = {
invalidCalendar: 'Calendar {0} not found',
invalidDate: 'Invalid {0} date',
invalidMonth: 'Invalid {0} month',
invalidYear: 'Invalid {0} year',
differentCalendars: 'Cannot mix {0} and {1} dates'
};
this.local = this.regionalOptions[''];
this.calendars = {};
this._localCals = {};
}
/** Create the calendars plugin.
<p>Provides support for various world calendars in a consistent manner.</p>
<p>Use the global instance, <code>$.calendars</code>, to access the functionality.</p>
@class Calendars
@example $.calendars.instance('julian').newDate(2014, 12, 25) */
$.extend(Calendars.prototype, {
/** Obtain a calendar implementation and localisation.
@memberof Calendars
@param {string} [name='gregorian'] The name of the calendar, e.g. 'gregorian', 'persian', 'islamic'.
@param {string} [language=''] The language code to use for localisation (default is English).
@return {Calendar} The calendar and localisation.
@throws Error if calendar not found.
@example $.calendars.instance()
$.calendars.instance('persian')
$.calendars.instance('hebrew', 'he') */
instance: function(name, language) {
name = (name || 'gregorian').toLowerCase();
language = language || '';
var cal = this._localCals[name + '-' + language];
if (!cal && this.calendars[name]) {
cal = new this.calendars[name](language);
this._localCals[name + '-' + language] = cal;
}
if (!cal) {
throw (this.local.invalidCalendar || this.regionalOptions[''].invalidCalendar).
replace(/\{0\}/, name);
}
return cal;
},
/** Create a new date - for today if no other parameters given.
@memberof Calendars
@param {CDate|number} [year] The date to copy or the year for the date.
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
@param {BaseCalendar|string} [calendar='gregorian'] The underlying calendar or the name of the calendar.
@param {string} [language=''] The language to use for localisation (default English).
@return {CDate} The new date.
@throws Error if an invalid date.
@example $.calendars.newDate()
$.calendars.newDate(otherDate)
$.calendars.newDate(2001, 1, 1)
$.calendars.newDate(1379, 10, 12, 'persian') */
newDate: function(year, month, day, calendar, language) {
calendar = ((typeof year !== 'undefined' && year !== null) && year.year ? year.calendar() :
(typeof calendar === 'string' ? this.instance(calendar, language) : calendar)) || this.instance();
return calendar.newDate(year, month, day);
},
/** A simple digit substitution function for localising numbers via the
{@linkcode GregorianCalendar.regionalOptions|Calendar digits} option.
@memberof Calendars
@param {string[]} digits The substitute digits, for 0 through 9.
@return {CalendarsDigits} The substitution function.
@example digits: $.calendars.substituteDigits(['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']) */
substituteDigits: function(digits) {
return function(value) {
return (value + '').replace(/[0-9]/g, function(digit) {
return digits[digit];
});
};
},
/** Digit substitution function for localising Chinese style numbers via the
{@linkcode GregorianCalendar.regionalOptions|Calendar digits} option.
@memberof Calendars
@param {string[]} digits The substitute digits, for 0 through 9.
@param {string[]} powers The characters denoting powers of 10, i.e. 1, 10, 100, 1000.
@return {CalendarsDigits} The substitution function.
@example digits: $.calendars.substituteChineseDigits(
['', '一', '二', '三', '四', '五', '六', '七', '八', '九'], ['', '十', '百', '千']) */
substituteChineseDigits: function(digits, powers) {
return function(value) {
var localNumber = '';
var power = 0;
while (value > 0) {
var units = value % 10;
localNumber = (units === 0 ? '' : digits[units] + powers[power]) + localNumber;
power++;
value = Math.floor(value / 10);
}
if (localNumber.indexOf(digits[1] + powers[1]) === 0) {
localNumber = localNumber.substr(1);
}
return localNumber || digits[0];
};
}
});
/** Generic date, based on a particular calendar.
@class CDate
@param {BaseCalendar} calendar The underlying calendar implementation.
@param {number} year The year for this date.
@param {number} month The month for this date.
@param {number} day The day for this date.
@return {CDate} The date object.
@throws Error if an invalid date. */
function CDate(calendar, year, month, day) {
this._calendar = calendar;
this._year = year;
this._month = month;
this._day = day;
if (this._calendar._validateLevel === 0 &&
!this._calendar.isValid(this._year, this._month, this._day)) {
throw ($.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate).
replace(/\{0\}/, this._calendar.local.name);
}
}
/** Pad a numeric value with leading zeroes.
@private
@param {number} value The number to format.
@param {number} length The minimum length.
@return {string} The formatted number. */
function pad(value, length) {
value = '' + value;
return '000000'.substring(0, length - value.length) + value;
}
$.extend(CDate.prototype, {
/** Create a new date.
@memberof CDate
@param {CDate|number} [year] The date to copy or the year for the date (default to this date).
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
@return {CDate} The new date.
@throws Error if an invalid date.
@example date.newDate()
date.newDate(otherDate)
date.newDate(2001, 1, 1) */
newDate: function(year, month, day) {
return this._calendar.newDate((typeof year === 'undefined' || year === null ? this : year), month, day);
},
/** Set or retrieve the year for this date.
@memberof CDate
@param {number} [year] The year for the date.
@return {number|CDate} The date's year (if no parameter) or the updated date.
@throws Error if an invalid date.
@example date.year(2001)
var year = date.year() */
year: function(year) {
return (arguments.length === 0 ? this._year : this.set(year, 'y'));
},
/** Set or retrieve the month for this date.
@memberof CDate
@param {number} [month] The month for the date.
@return {number|CDate} The date's month (if no parameter) or the updated date.
@throws Error if an invalid date.
@example date.month(1)
var month = date.month() */
month: function(month) {
return (arguments.length === 0 ? this._month : this.set(month, 'm'));
},
/** Set or retrieve the day for this date.
@memberof CDate
@param {number} [day] The day for the date.
@return {number|CData} The date's day (if no parameter) or the updated date.
@throws Error if an invalid date.
@example date.day(1)
var day = date.day() */
day: function(day) {
return (arguments.length === 0 ? this._day : this.set(day, 'd'));
},
/** Set new values for this date.
@memberof CDate
@param {number} year The year for the date.
@param {number} month The month for the date.
@param {number} day The day for the date.
@return {CDate} The updated date.
@throws Error if an invalid date.
@example date.date(2001, 1, 1) */
date: function(year, month, day) {
if (!this._calendar.isValid(year, month, day)) {
throw ($.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate).
replace(/\{0\}/, this._calendar.local.name);
}
this._year = year;
this._month = month;
this._day = day;
return this;
},
/** Determine whether this date is in a leap year.
@memberof CDate
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
@example if (date.leapYear()) ...*/
leapYear: function() {
return this._calendar.leapYear(this);
},
/** Retrieve the epoch designator for this date, e.g. BCE or CE.
@memberof CDate
@return {string} The current epoch.
@example var epoch = date.epoch() */
epoch: function() {
return this._calendar.epoch(this);
},
/** Format the year, if not a simple sequential number.
@memberof CDate
@return {string} The formatted year.
@example var year = date.formatYear() */
formatYear: function() {
return this._calendar.formatYear(this);
},
/** Retrieve the month of the year for this date,
i.e. the month's position within a numbered year.
@memberof CDate
@return {number} The month of the year: <code>minMonth</code> to months per year.
@example var month = date.monthOfYear() */
monthOfYear: function() {
return this._calendar.monthOfYear(this);
},
/** Retrieve the week of the year for this date.
@memberof CDate
@return {number} The week of the year: 1 to weeks per year.
@example var week = date.weekOfYear() */
weekOfYear: function() {
return this._calendar.weekOfYear(this);
},
/** Retrieve the number of days in the year for this date.
@memberof CDate
@return {number} The number of days in this year.
@example var days = date.daysInYear() */
daysInYear: function() {
return this._calendar.daysInYear(this);
},
/** Retrieve the day of the year for this date.
@memberof CDate
@return {number} The day of the year: 1 to days per year.
@example var doy = date.dayOfYear() */
dayOfYear: function() {
return this._calendar.dayOfYear(this);
},
/** Retrieve the number of days in the month for this date.
@memberof CDate
@return {number} The number of days.
@example var days = date.daysInMonth() */
daysInMonth: function() {
return this._calendar.daysInMonth(this);
},
/** Retrieve the day of the week for this date.
@memberof CDate
@return {number} The day of the week: 0 to number of days - 1.
@example var dow = date.dayOfWeek() */
dayOfWeek: function() {
return this._calendar.dayOfWeek(this);
},
/** Determine whether this date is a week day.
@memberof CDate
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
@example if (date.weekDay()) ... */
weekDay: function() {
return this._calendar.weekDay(this);
},
/** Retrieve additional information about this date.
@memberof CDate
@return {object} Additional information - contents depends on calendar.
@example var info = date.extraInfo() */
extraInfo: function() {
return this._calendar.extraInfo(this);
},
/** Add period(s) to a date.
@memberof CDate
@param {number} offset The number of periods to adjust by.
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
@return {CDate} The updated date.
@example date.add(10, 'd') */
add: function(offset, period) {
return this._calendar.add(this, offset, period);
},
/** Set a portion of the date.
@memberof CDate
@param {number} value The new value for the period.
@param {string} period One of 'y' for year, 'm' for month, 'd' for day.
@return {CDate} The updated date.
@throws Error if not a valid date.
@example date.set(10, 'd') */
set: function(value, period) {
return this._calendar.set(this, value, period);
},
/** Compare this date to another date.
@memberof CDate
@param {CDate} date The other date.
@return {number} -1 if this date is before the other date,
0 if they are equal, or +1 if this date is after the other date.
@example if (date1.compareTo(date2) < 0) ... */
compareTo: function(date) {
if (this._calendar.name !== date._calendar.name) {
throw ($.calendars.local.differentCalendars || $.calendars.regionalOptions[''].differentCalendars).
replace(/\{0\}/, this._calendar.local.name).replace(/\{1\}/, date._calendar.local.name);
}
var c = (this._year !== date._year ? this._year - date._year :
this._month !== date._month ? this.monthOfYear() - date.monthOfYear() :
this._day - date._day);
return (c === 0 ? 0 : (c < 0 ? -1 : +1));
},
/** Retrieve the calendar backing this date.
@memberof CDate
@return {BaseCalendar} The calendar implementation.
@example var cal = date.calendar() */
calendar: function() {
return this._calendar;
},
/** Retrieve the Julian date equivalent for this date,
i.e. days since January 1, 4713 BCE Greenwich noon.
@memberof CDate
@return {number} The equivalent Julian date.
@example var jd = date.toJD() */
toJD: function() {
return this._calendar.toJD(this);
},
/** Create a new date from a Julian date.
@memberof CDate
@param {number} jd The Julian date to convert.
@return {CDate} The equivalent date.
@example var date2 = date1.fromJD(jd) */
fromJD: function(jd) {
return this._calendar.fromJD(jd);
},
/** Convert this date to a standard (Gregorian) JavaScript Date.
@memberof CDate
@return {Date} The equivalent JavaScript date.
@example var jsd = date.toJSDate() */
toJSDate: function() {
return this._calendar.toJSDate(this);
},
/** Create a new date from a standard (Gregorian) JavaScript Date.
@memberof CDate
@param {Date} jsd The JavaScript date to convert.
@return {CDate} The equivalent date.
@example var date2 = date1.fromJSDate(jsd) */
fromJSDate: function(jsd) {
return this._calendar.fromJSDate(jsd);
},
/** Convert to a string for display.
@memberof CDate
@return {string} This date as a string. */
toString: function() {
return (this.year() < 0 ? '-' : '') + pad(Math.abs(this.year()), 4) +
'-' + pad(this.month(), 2) + '-' + pad(this.day(), 2);
}
});
/** Basic functionality for all calendars.
Other calendars should extend this:
<pre>OtherCalendar.prototype = new BaseCalendar();</pre>
@class BaseCalendar */
function BaseCalendar() {
this.shortYearCutoff = '+10';
}
$.extend(BaseCalendar.prototype, {
_validateLevel: 0, // "Stack" to turn validation on/off
/** Create a new date within this calendar - today if no parameters given.
@memberof BaseCalendar
@param {CDate|number} year The date to duplicate or the year for the date.
@param {number} [month] The month for the date (if numeric <code>year</code> specified above).
@param {number} [day] The day for the date (if numeric <code>year</code> specified above).
@return {CDate} The new date.
@throws Error if not a valid date or a different calendar is used.
@example var date = calendar.newDate(2014, 1, 26)
var date2 = calendar.newDate(date1)
var today = calendar.newDate() */
newDate: function(year, month, day) {
if (typeof year === 'undefined' || year === null) {
return this.today();
}
if (year.year) {
this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
day = year.day();
month = year.month();
year = year.year();
}
return new CDate(this, year, month, day);
},
/** Create a new date for today.
@memberof BaseCalendar
@return {CDate} Today's date.
@example var today = calendar.today() */
today: function() {
return this.fromJSDate(new Date());
},
/** Retrieve the epoch designator for this date.
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {string} The current epoch.
@throws Error if an invalid year or a different calendar is used.
@example var epoch = calendar.epoch(date)
var epoch = calendar.epoch(2014) */
epoch: function(year) {
var date = this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
return (date.year() < 0 ? this.local.epochs[0] : this.local.epochs[1]);
},
/** Format the year, if not a simple sequential number
@memberof BaseCalendar
@param {CDate|number} year The date to format or the year to format.
@return {string} The formatted year.
@throws Error if an invalid year or a different calendar is used.
@example var year = calendar.formatYear(date)
var year = calendar.formatYear(2014) */
formatYear: function(year) {
var date = this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
return (date.year() < 0 ? '-' : '') + pad(Math.abs(date.year()), 4);
},
/** Retrieve the number of months in a year.
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {number} The number of months.
@throws Error if an invalid year or a different calendar is used.
@example var months = calendar.monthsInYear(date)
var months = calendar.monthsInYear(2014) */
monthsInYear: function(year) {
this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
return 12;
},
/** Calculate the month's ordinal position within the year -
for those calendars that don't start at month 1!
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
@return {number} The ordinal position, starting from <code>minMonth</code>.
@throws Error if an invalid year/month or a different calendar is used.
@example var pos = calendar.monthOfYear(date)
var pos = calendar.monthOfYear(2014, 7) */
monthOfYear: function(year, month) {
var date = this._validate(year, month, this.minDay,
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
return (date.month() + this.monthsInYear(date) - this.firstMonth) %
this.monthsInYear(date) + this.minMonth;
},
/** Calculate actual month from ordinal position, starting from <code>minMonth</code>.
@memberof BaseCalendar
@param {number} year The year to examine.
@param {number} ord The month's ordinal position.
@return {number} The month's number.
@throws Error if an invalid year/month.
@example var month = calendar.fromMonthOfYear(2014, 7) */
fromMonthOfYear: function(year, ord) {
var m = (ord + this.firstMonth - 2 * this.minMonth) %
this.monthsInYear(year) + this.minMonth;
this._validate(year, m, this.minDay,
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
return m;
},
/** Retrieve the number of days in a year.
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {number} The number of days.
@throws Error if an invalid year or a different calendar is used.
@example var days = calendar.daysInYear(date)
var days = calendar.daysInYear(2014) */
daysInYear: function(year) {
var date = this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
return (this.leapYear(date) ? 366 : 365);
},
/** Retrieve the day of the year for a date.
@memberof BaseCalendar
@param {CDate|number} year The date to convert or the year to convert.
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
@return {number} The day of the year: 1 to days per year.
@throws Error if an invalid date or a different calendar is used.
@example var doy = calendar.dayOfYear(date)
var doy = calendar.dayOfYear(2014, 7, 1) */
dayOfYear: function(year, month, day) {
var date = this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
return date.toJD() - this.newDate(date.year(),
this.fromMonthOfYear(date.year(), this.minMonth), this.minDay).toJD() + 1;
},
/** Retrieve the number of days in a week.
@memberof BaseCalendar
@return {number} The number of days.
@example var days = calendar.daysInWeek() */
daysInWeek: function() {
return 7;
},
/** Retrieve the day of the week for a date.
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
@return {number} The day of the week: 0 to number of days - 1.
@throws Error if an invalid date or a different calendar is used.
@example var dow = calendar.dayOfWeek(date)
var dow = calendar.dayOfWeek(2014, 1, 26) */
dayOfWeek: function(year, month, day) {
var date = this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
return (Math.floor(this.toJD(date)) + 2) % this.daysInWeek();
},
/** Retrieve additional information about a date.
@memberof BaseCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
@return {object} Additional information - content depends on calendar.
@throws Error if an invalid date or a different calendar is used.
@example var info = calendar.extraInfo(date)
var info = calendar.extraInfo(2014, 1, 26) */
extraInfo: function(year, month, day) {
this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
return {};
},
/** Add period(s) to a date.
Cater for no year zero.
@memberof BaseCalendar
@param {CDate} date The starting date.
@param {number} offset The number of periods to adjust by.
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
@return {CDate} The updated date.
@throws Error if a different calendar is used.
@example calendar.add(date, 10, 'd') */
add: function(date, offset, period) {
this._validate(date, this.minMonth, this.minDay,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
return this._correctAdd(date, this._add(date, offset, period), offset, period);
},
/** Add period(s) to a date.
@memberof BaseCalendar
@private
@param {CDate} date The starting date.
@param {number} offset The number of periods to adjust by.
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
@return {number[]} The updated date as year, month, and day. */
_add: function(date, offset, period) {
this._validateLevel++;
var d;
if (period === 'd' || period === 'w') {
var jd = date.toJD() + offset * (period === 'w' ? this.daysInWeek() : 1);
d = date.calendar().fromJD(jd);
this._validateLevel--;
return [d.year(), d.month(), d.day()];
}
try {
var y = date.year() + (period === 'y' ? offset : 0);
var m = date.monthOfYear() + (period === 'm' ? offset : 0);
d = date.day();
var resyncYearMonth = function(calendar) {
while (m < calendar.minMonth) {
y--;
m += calendar.monthsInYear(y);
}
var yearMonths = calendar.monthsInYear(y);
while (m > yearMonths - 1 + calendar.minMonth) {
y++;
m -= yearMonths;
yearMonths = calendar.monthsInYear(y);
}
};
if (period === 'y') {
if (date.month() !== this.fromMonthOfYear(y, m)) { // Hebrew
m = this.newDate(y, date.month(), this.minDay).monthOfYear();
}
m = Math.min(m, this.monthsInYear(y));
d = Math.min(d, this.daysInMonth(y, this.fromMonthOfYear(y, m)));
}
else if (period === 'm') {
resyncYearMonth(this);
d = Math.min(d, this.daysInMonth(y, this.fromMonthOfYear(y, m)));
}
var ymd = [y, this.fromMonthOfYear(y, m), d];
this._validateLevel--;
return ymd;
}
catch (e) {
this._validateLevel--;
throw e;
}
},
/** Correct a candidate date after adding period(s) to a date.
Handle no year zero if necessary.
@memberof BaseCalendar
@private
@param {CDate} date The starting date.
@param {number[]} ymd The added date.
@param {number} offset The number of periods to adjust by.
@param {string} period One of 'y' for years, 'm' for months, 'w' for weeks, 'd' for days.
@return {CDate} The updated date. */
_correctAdd: function(date, ymd, offset, period) {
if (!this.hasYearZero && (period === 'y' || period === 'm')) {
if (ymd[0] === 0 || // In year zero
(date.year() > 0) !== (ymd[0] > 0)) { // Crossed year zero
var adj = {y: [1, 1, 'y'], m: [1, this.monthsInYear(-1), 'm'],
w: [this.daysInWeek(), this.daysInYear(-1), 'd'],
d: [1, this.daysInYear(-1), 'd']}[period];
var dir = (offset < 0 ? -1 : +1);
ymd = this._add(date, offset * adj[0] + dir * adj[1], adj[2]);
}
}
return date.date(ymd[0], ymd[1], ymd[2]);
},
/** Set a portion of the date.
@memberof BaseCalendar
@param {CDate} date The starting date.
@param {number} value The new value for the period.
@param {string} period One of 'y' for year, 'm' for month, 'd' for day.
@return {CDate} The updated date.
@throws Error if an invalid date or a different calendar is used.
@example calendar.set(date, 10, 'd') */
set: function(date, value, period) {
this._validate(date, this.minMonth, this.minDay,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
var y = (period === 'y' ? value : date.year());
var m = (period === 'm' ? value : date.month());
var d = (period === 'd' ? value : date.day());
if (period === 'y' || period === 'm') {
d = Math.min(d, this.daysInMonth(y, m));
}
return date.date(y, m, d);
},
/** Determine whether a date is valid for this calendar.
@memberof BaseCalendar
@param {number} year The year to examine.
@param {number} month The month to examine.
@param {number} day The day to examine.
@return {boolean} <code>true</code> if a valid date, <code>false</code> if not.
@example if (calendar.isValid(2014, 2, 31)) ... */
isValid: function(year, month, day) {
this._validateLevel++;
var valid = (this.hasYearZero || year !== 0);
if (valid) {
var date = this.newDate(year, month, this.minDay);
valid = (month >= this.minMonth && month - this.minMonth < this.monthsInYear(date)) &&
(day >= this.minDay && day - this.minDay < this.daysInMonth(date));
}
this._validateLevel--;
return valid;
},
/** Convert the date to a standard (Gregorian) JavaScript Date.
@memberof BaseCalendar
@param {CDate|number} year The date to convert or the year to convert.
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
@return {Date} The equivalent JavaScript date.
@throws Error if an invalid date or a different calendar is used.
@example var jsd = calendar.toJSDate(date)
var jsd = calendar.toJSDate(2014, 1, 26) */
toJSDate: function(year, month, day) {
var date = this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
return $.calendars.instance().fromJD(this.toJD(date)).toJSDate();
},
/** Convert the date from a standard (Gregorian) JavaScript Date.
@memberof BaseCalendar
@param {Date} jsd The JavaScript date.
@return {CDate} The equivalent calendar date.
@example var date = calendar.fromJSDate(jsd) */
fromJSDate: function(jsd) {
return this.fromJD($.calendars.instance().fromJSDate(jsd).toJD());
},
/** Check that a candidate date is from the same calendar and is valid.
@memberof BaseCalendar
@private
@param {CDate|number} year The date to validate or the year to validate.
@param {number} [month] The month to validate (if numeric <code>year</code> specified above).
@param {number} [day] The day to validate (if numeric <code>year</code> specified above).
@param {string} error Error message if invalid.
@throws Error if an invalid date or a different calendar is used. */
_validate: function(year, month, day, error) {
if (year.year) {
if (this._validateLevel === 0 && this.name !== year.calendar().name) {
throw ($.calendars.local.differentCalendars || $.calendars.regionalOptions[''].differentCalendars).
replace(/\{0\}/, this.local.name).replace(/\{1\}/, year.calendar().local.name);
}
return year;
}
try {
this._validateLevel++;
if (this._validateLevel === 1 && !this.isValid(year, month, day)) {
throw error.replace(/\{0\}/, this.local.name);
}
var date = this.newDate(year, month, day);
this._validateLevel--;
return date;
}
catch (e) {
this._validateLevel--;
throw e;
}
}
});
/** Implementation of the Proleptic Gregorian Calendar.
See <a href=":http://en.wikipedia.org/wiki/Gregorian_calendar">http://en.wikipedia.org/wiki/Gregorian_calendar</a>
and <a href="http://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar">http://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar</a>.
@class GregorianCalendar
@augments BaseCalendar
@param {string} [language=''] The language code (default English) for localisation. */
function GregorianCalendar(language) {
this.local = this.regionalOptions[language] || this.regionalOptions[''];
}
GregorianCalendar.prototype = new BaseCalendar();
$.extend(GregorianCalendar.prototype, {
/** The calendar name.
@memberof GregorianCalendar */
name: 'Gregorian',
/** Julian date of start of Gregorian epoch: 1 January 0001 CE.
@memberof GregorianCalendar */
jdEpoch: 1721425.5,
/** Days per month in a common year.
@memberof GregorianCalendar */
daysPerMonth: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
/** <code>true</code> if has a year zero, <code>false</code> if not.
@memberof GregorianCalendar */
hasYearZero: false,
/** The minimum month number.
@memberof GregorianCalendar */
minMonth: 1,
/** The first month in the year.
@memberof GregorianCalendar */
firstMonth: 1,
/** The minimum day number.
@memberof GregorianCalendar */
minDay: 1,
/** Convert a number into a localised form.
@callback CalendarsDigits
@param {number} value The number to convert.
@return {string} The localised number.
@example digits: $.calendars.substituteDigits(['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']) */
/** Localisations for the plugin.
Entries are objects indexed by the language code ('' being the default US/English).
Each object has the following attributes.
@memberof GregorianCalendar
@property {string} [name='Gregorian'] The calendar name.
@property {string[]} [epochs=['BCE','CE']] The epoch names.
@property {string[]} [monthNames=[...]] The long names of the months of the year.
@property {string[]} [monthNamesShort=[...]] The short names of the months of the year.
@property {string[]} [dayNames=[...]] The long names of the days of the week.
@property {string[]} [dayNamesShort=[...]] The short names of the days of the week.
@property {string[]} [dayNamesMin=[...]] The minimal names of the days of the week.
@property {CalendarsDigits} [digits=null] Convert numbers to localised versions.
@property {string} [dateFormat='mm/dd/yyyy'] The date format for this calendar.
See the options on {@linkcode BaseCalendar.formatDate|formatDate} for details.
@property {number} [firstDay=0] The number of the first day of the week, starting at 0.
@property {boolean} [isRTL=false] <code>true</code> if this localisation reads right-to-left. */
regionalOptions: { // Localisations
'': {
name: 'Gregorian',
epochs: ['BCE', 'CE'],
monthNames: ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'],
monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
digits: null,
dateFormat: 'mm/dd/yyyy',
firstDay: 0,
isRTL: false
}
},
/** Determine whether this date is in a leap year.
@memberof GregorianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@return {boolean} <code>true</code> if this is a leap year, <code>false</code> if not.
@throws Error if an invalid year or a different calendar is used. */
leapYear: function(year) {
var date = this._validate(year, this.minMonth, this.minDay,
$.calendars.local.invalidYear || $.calendars.regionalOptions[''].invalidYear);
year = date.year() + (date.year() < 0 ? 1 : 0); // No year zero
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
},
/** Determine the week of the year for a date - ISO 8601.
@memberof GregorianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
@return {number} The week of the year, starting from 1.
@throws Error if an invalid date or a different calendar is used. */
weekOfYear: function(year, month, day) {
// Find Thursday of this week starting on Monday
var checkDate = this.newDate(year, month, day);
checkDate.add(4 - (checkDate.dayOfWeek() || 7), 'd');
return Math.floor((checkDate.dayOfYear() - 1) / 7) + 1;
},
/** Retrieve the number of days in a month.
@memberof GregorianCalendar
@param {CDate|number} year The date to examine or the year of the month.
@param {number} [month] The month (if numeric <code>year</code> specified above).
@return {number} The number of days in this month.
@throws Error if an invalid month/year or a different calendar is used. */
daysInMonth: function(year, month) {
var date = this._validate(year, month, this.minDay,
$.calendars.local.invalidMonth || $.calendars.regionalOptions[''].invalidMonth);
return this.daysPerMonth[date.month() - 1] +
(date.month() === 2 && this.leapYear(date.year()) ? 1 : 0);
},
/** Determine whether this date is a week day.
@memberof GregorianCalendar
@param {CDate|number} year The date to examine or the year to examine.
@param {number} [month] The month to examine (if numeric <code>year</code> specified above).
@param {number} [day] The day to examine (if numeric <code>year</code> specified above).
@return {boolean} <code>true</code> if a week day, <code>false</code> if not.
@throws Error if an invalid date or a different calendar is used. */
weekDay: function(year, month, day) {
return (this.dayOfWeek(year, month, day) || 7) < 6;
},
/** Retrieve the Julian date equivalent for this date,
i.e. days since January 1, 4713 BCE Greenwich noon.
@memberof GregorianCalendar
@param {CDate|number} year The date to convert or the year to convert.
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
@return {number} The equivalent Julian date.
@throws Error if an invalid date or a different calendar is used. */
toJD: function(year, month, day) {
var date = this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
year = date.year();
month = date.month();
day = date.day();
if (year < 0) { year++; } // No year zero
// Jean Meeus algorithm, "Astronomical Algorithms", 1991
if (month < 3) {
month += 12;
year--;
}
var a = Math.floor(year / 100);
var b = 2 - a + Math.floor(a / 4);
return Math.floor(365.25 * (year + 4716)) +
Math.floor(30.6001 * (month + 1)) + day + b - 1524.5;
},
/** Create a new date from a Julian date.
@memberof GregorianCalendar
@param {number} jd The Julian date to convert.
@return {CDate} The equivalent date. */
fromJD: function(jd) {
// Jean Meeus algorithm, "Astronomical Algorithms", 1991
var z = Math.floor(jd + 0.5);
var a = Math.floor((z - 1867216.25) / 36524.25);
a = z + 1 + a - Math.floor(a / 4);
var b = a + 1524;
var c = Math.floor((b - 122.1) / 365.25);
var d = Math.floor(365.25 * c);
var e = Math.floor((b - d) / 30.6001);
var day = b - d - Math.floor(e * 30.6001);
var month = e - (e > 13.5 ? 13 : 1);
var year = c - (month > 2.5 ? 4716 : 4715);
if (year <= 0) { year--; } // No year zero
return this.newDate(year, month, day);
},
/** Convert this date to a standard (Gregorian) JavaScript Date.
@memberof GregorianCalendar
@param {CDate|number} year The date to convert or the year to convert.
@param {number} [month] The month to convert (if numeric <code>year</code> specified above).
@param {number} [day] The day to convert (if numeric <code>year</code> specified above).
@return {Date} The equivalent JavaScript date.
@throws Error if an invalid date or a different calendar is used. */
toJSDate: function(year, month, day) {
var date = this._validate(year, month, day,
$.calendars.local.invalidDate || $.calendars.regionalOptions[''].invalidDate);
var jsd = new Date(date.year(), date.month() - 1, date.day());
jsd.setHours(0);
jsd.setMinutes(0);
jsd.setSeconds(0);
jsd.setMilliseconds(0);
// Hours may be non-zero on daylight saving cut-over:
// > 12 when midnight changeover, but then cannot generate
// midnight datetime, so jump to 1AM, otherwise reset.
jsd.setHours(jsd.getHours() > 12 ? jsd.getHours() + 2 : 0);
return jsd;
},
/** Create a new date from a standard (Gregorian) JavaScript Date.
@memberof GregorianCalendar
@param {Date} jsd The JavaScript date to convert.
@return {CDate} The equivalent date. */
fromJSDate: function(jsd) {
return this.newDate(jsd.getFullYear(), jsd.getMonth() + 1, jsd.getDate());
}
});
// Singleton manager
$.calendars = new Calendars();
// Date template
$.calendars.cdate = CDate;
// Base calendar template
$.calendars.baseCalendar = BaseCalendar;
// Gregorian calendar implementation
$.calendars.calendars.gregorian = GregorianCalendar;
})(jQuery);

View File

@@ -0,0 +1,463 @@
/* http://keith-wood.name/calendars.html
Calendars extras for jQuery v2.1.0.
Written by Keith Wood (wood.keith{at}optusnet.com.au) August 2009.
Available under the MIT (http://keith-wood.name/licence.html) license.
Please attribute the author if you use it. */
(function($) { // Hide scope, no $ conflict
'use strict';
$.extend($.calendars.regionalOptions[''], {
invalidArguments: 'Invalid arguments',
invalidFormat: 'Cannot format a date from another calendar',
missingNumberAt: 'Missing number at position {0}',
unknownNameAt: 'Unknown name at position {0}',
unexpectedLiteralAt: 'Unexpected literal at position {0}',
unexpectedText: 'Additional text found at end'
});
$.calendars.local = $.calendars.regionalOptions[''];
$.extend($.calendars.cdate.prototype, {
/** Format this date.
Found in the <code>jquery.calendars.plus.js</code> module.
@memberof CDate
@param {string} [format] The date format to use (see {@linkcode BaseCalendar.formatDate|formatDate}).
@param {object} [settings] Options for the <code>formatDate</code> function.
@return {string} The formatted date. */
formatDate: function(format, settings) {
if (typeof format !== 'string') {
settings = format;
format = '';
}
return this._calendar.formatDate(format || '', this, settings);
}
});
$.extend($.calendars.baseCalendar.prototype, {
UNIX_EPOCH: $.calendars.instance().newDate(1970, 1, 1).toJD(),
SECS_PER_DAY: 24 * 60 * 60,
TICKS_EPOCH: $.calendars.instance().jdEpoch, // 1 January 0001 CE
TICKS_PER_DAY: 24 * 60 * 60 * 10000000,
/** Date format for ATOM (RFC 3339/ISO 8601) - 'yyyy-mm-dd'.
@memberof BaseCalendar */
ATOM: 'yyyy-mm-dd',
/** Date format for cookies - 'D, dd M yyyy'.
@memberof BaseCalendar */
COOKIE: 'D, dd M yyyy',
/** Date format for the full date - 'DD, MM d, yyyy'.
@memberof BaseCalendar */
FULL: 'DD, MM d, yyyy',
/** Date format for ISO 8601 - 'yyyy-mm-dd'.
@memberof BaseCalendar */
ISO_8601: 'yyyy-mm-dd',
/** Date format for Julian date - days since January 1, 4713 BCE Greenwich noon.
@memberof BaseCalendar */
JULIAN: 'J',
/** Date format for RFC 822 - 'D, d M yy'.
@memberof BaseCalendar */
RFC_822: 'D, d M yy',
/** Date format for RFC 850 - 'DD, dd-M-yy'.
@memberof BaseCalendar */
RFC_850: 'DD, dd-M-yy',
/** Date format for RFC 1036 - 'D, d M yy'.
@memberof BaseCalendar */
RFC_1036: 'D, d M yy',
/** Date format for RFC 1123 - 'D, d M yyyy'.
@memberof BaseCalendar */
RFC_1123: 'D, d M yyyy',
/** Date format for RFC 2822 - 'D, d M yyyy'.
@memberof BaseCalendar */
RFC_2822: 'D, d M yyyy',
/** Date format for RSS (RFC 822) - 'D, d M yy'.
@memberof BaseCalendar */
RSS: 'D, d M yy',
/** Date format for Windows ticks - number of 100-nanosecond ticks since 1 January 0001 00:00:00 UTC.
@memberof BaseCalendar */
TICKS: '!',
/** Date format for Unix timestamp - number of seconds elapsed since the
start of the Unix epoch at 1 January 1970 00:00:00 UTC.
@memberof BaseCalendar */
TIMESTAMP: '@',
/** Date format for W3C (ISO 8601) - 'yyyy-mm-dd'.
@memberof BaseCalendar */
W3C: 'yyyy-mm-dd',
/** Format a date object into a string value.
The format can be combinations of the following:
<ul>
<li>d - day of month (no leading zero)</li>
<li>dd - day of month (two digit)</li>
<li>o - day of year (no leading zeros)</li>
<li>oo - day of year (three digit)</li>
<li>D - day name short</li>
<li>DD - day name long</li>
<li>w - week of year (no leading zero)</li>
<li>ww - week of year (two digit)</li>
<li>m - month of year (no leading zero)</li>
<li>mm - month of year (two digit)</li>
<li>M - month name short</li>
<li>MM - month name long</li>
<li>yy - year (two digit)</li>
<li>yyyy - year (four digit)</li>
<li>YYYY - formatted year</li>
<li>J - Julian date (days since January 1, 4713 BCE Greenwich noon)</li>
<li>@ - Unix timestamp (s since 01/01/1970)</li>
<li>! - Windows ticks (100ns since 01/01/0001)</li>
<li>'...' - literal text</li>
<li>'' - single quote</li>
</ul>
Found in the <code>jquery.calendars.plus.js</code> module.
@memberof BaseCalendar
@param {string} [format] The desired format of the date (defaults to calendar format).
@param {CDate} date The date value to format.
@param {object} [settings] Addition options, whose attributes include:
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
@param {string[]} [settings.monthNames] Names of the months.
@param {boolean} [settings.localNumbers=false] <code>true</code> to localise numbers (if available),
<code>false</code> to use normal Arabic numerals.
@return {string} The date in the above format.
@throws Errors if the date is from a different calendar. */
formatDate: function(format, date, settings) {
if (typeof format !== 'string') {
settings = date;
date = format;
format = '';
}
if (!date) {
return '';
}
if (date.calendar() !== this) {
throw $.calendars.local.invalidFormat || $.calendars.regionalOptions[''].invalidFormat;
}
format = format || this.local.dateFormat;
settings = settings || {};
var dayNamesShort = settings.dayNamesShort || this.local.dayNamesShort;
var dayNames = settings.dayNames || this.local.dayNames;
var monthNamesShort = settings.monthNamesShort || this.local.monthNamesShort;
var monthNames = settings.monthNames || this.local.monthNames;
var localNumbers = settings.localNumbers || this.local.localNumbers;
// Check whether a format character is doubled
var doubled = function(match, step) {
var matches = 1;
while (iFormat + matches < format.length && format.charAt(iFormat + matches) === match) {
matches++;
}
iFormat += matches - 1;
return Math.floor(matches / (step || 1)) > 1;
};
// Format a number, with leading zeroes if necessary
var formatNumber = function(match, value, len, step) {
var num = '' + value;
if (doubled(match, step)) {
while (num.length < len) {
num = '0' + num;
}
}
return num;
};
// Format a name, short or long as requested
var formatName = function(match, value, shortNames, longNames) {
return (doubled(match) ? longNames[value] : shortNames[value]);
};
// Localise numbers if requested and available
var localiseNumbers = localNumbers && this.local.digits ?
this.local.digits : function(value) { return value; };
var output = '';
var literal = false;
for (var iFormat = 0; iFormat < format.length; iFormat++) {
if (literal) {
if (format.charAt(iFormat) === '\'' && !doubled('\'')) {
literal = false;
}
else {
output += format.charAt(iFormat);
}
}
else {
switch (format.charAt(iFormat)) {
case 'd':
output += localiseNumbers(formatNumber('d', date.day(), 2));
break;
case 'D':
output += formatName('D', date.dayOfWeek(), dayNamesShort, dayNames);
break;
case 'o':
output += formatNumber('o', date.dayOfYear(), 3);
break;
case 'w':
output += formatNumber('w', date.weekOfYear(), 2);
break;
case 'm':
output += localiseNumbers(formatNumber('m', date.month(), 2));
break;
case 'M':
output += formatName('M', date.month() - this.minMonth, monthNamesShort, monthNames);
break;
case 'y':
output += localiseNumbers(doubled('y', 2) ? date.year() :
(date.year() % 100 < 10 ? '0' : '') + date.year() % 100);
break;
case 'Y':
doubled('Y', 2);
output += date.formatYear();
break;
case 'J':
output += date.toJD();
break;
case '@':
output += (date.toJD() - this.UNIX_EPOCH) * this.SECS_PER_DAY;
break;
case '!':
output += (date.toJD() - this.TICKS_EPOCH) * this.TICKS_PER_DAY;
break;
case '\'':
if (doubled('\'')) {
output += '\'';
}
else {
literal = true;
}
break;
default:
output += format.charAt(iFormat);
}
}
}
return output;
},
/** Parse a string value into a date object.
See {@linkcode BaseCalendar.formatDate|formatDate} for the possible formats, plus:
<ul>
<li>* - ignore rest of string</li>
</ul>
Found in the <code>jquery.calendars.plus.js</code> module.
@memberof BaseCalendar
@param {string} format The expected format of the date ('' for default calendar format).
@param {string} value The date in the above format.
@param {object} [settings] Additional options whose attributes include:
@param {number} [settings.shortYearCutoff] The cutoff year for determining the century.
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
@param {string[]} [settings.monthNames] Names of the months.
@return {CDate} The extracted date value or <code>null</code> if value is blank.
@throws Errors if the format and/or value are missing,
if the value doesn't match the format, or if the date is invalid. */
parseDate: function(format, value, settings) {
if (typeof value === 'undefined' || value === null) {
throw $.calendars.local.invalidArguments || $.calendars.regionalOptions[''].invalidArguments;
}
value = (typeof value === 'object' ? value.toString() : value + '');
if (value === '') {
return null;
}
format = format || this.local.dateFormat;
settings = settings || {};
var shortYearCutoff = settings.shortYearCutoff || this.shortYearCutoff;
shortYearCutoff = (typeof shortYearCutoff !== 'string' ? shortYearCutoff :
this.today().year() % 100 + parseInt(shortYearCutoff, 10));
var dayNamesShort = settings.dayNamesShort || this.local.dayNamesShort;
var dayNames = settings.dayNames || this.local.dayNames;
var monthNamesShort = settings.monthNamesShort || this.local.monthNamesShort;
var monthNames = settings.monthNames || this.local.monthNames;
var jd = -1;
var year = -1;
var month = -1;
var day = -1;
var doy = -1;
var shortYear = false;
var literal = false;
// Check whether a format character is doubled
var doubled = function(match, step) {
var matches = 1;
while (iFormat + matches < format.length && format.charAt(iFormat + matches) === match) {
matches++;
}
iFormat += matches - 1;
return Math.floor(matches / (step || 1)) > 1;
};
// Extract a number from the string value
var getNumber = function(match, step) {
var isDoubled = doubled(match, step);
var size = [2, 3, isDoubled ? 4 : 2, isDoubled ? 4 : 2, 10, 11, 20]['oyYJ@!'.indexOf(match) + 1];
var digits = new RegExp('^-?\\d{1,' + size + '}');
var num = value.substring(iValue).match(digits);
if (!num) {
throw ($.calendars.local.missingNumberAt || $.calendars.regionalOptions[''].missingNumberAt).
replace(/\{0\}/, iValue);
}
iValue += num[0].length;
return parseInt(num[0], 10);
};
// Extract a name from the string value and convert to an index
var calendar = this;
var getName = function(match, shortNames, longNames, step) {
var names = (doubled(match, step) ? longNames : shortNames);
for (var i = 0; i < names.length; i++) {
if (value.substr(iValue, names[i].length).toLowerCase() === names[i].toLowerCase()) {
iValue += names[i].length;
return i + calendar.minMonth;
}
}
throw ($.calendars.local.unknownNameAt || $.calendars.regionalOptions[''].unknownNameAt).
replace(/\{0\}/, iValue);
};
// Confirm that a literal character matches the string value
var checkLiteral = function() {
if (value.charAt(iValue) !== format.charAt(iFormat)) {
throw ($.calendars.local.unexpectedLiteralAt ||
$.calendars.regionalOptions[''].unexpectedLiteralAt).replace(/\{0\}/, iValue);
}
iValue++;
};
var iValue = 0;
for (var iFormat = 0; iFormat < format.length; iFormat++) {
if (literal) {
if (format.charAt(iFormat) === '\'' && !doubled('\'')) {
literal = false;
}
else {
checkLiteral();
}
}
else {
switch (format.charAt(iFormat)) {
case 'd':
day = getNumber('d');
break;
case 'D':
getName('D', dayNamesShort, dayNames);
break;
case 'o':
doy = getNumber('o');
break;
case 'w':
getNumber('w');
break;
case 'm':
month = getNumber('m');
break;
case 'M':
month = getName('M', monthNamesShort, monthNames);
break;
case 'y':
var iSave = iFormat;
shortYear = !doubled('y', 2);
iFormat = iSave;
year = getNumber('y', 2);
break;
case 'Y':
year = getNumber('Y', 2);
break;
case 'J':
jd = getNumber('J') + 0.5;
if (value.charAt(iValue) === '.') {
iValue++;
getNumber('J');
}
break;
case '@':
jd = getNumber('@') / this.SECS_PER_DAY + this.UNIX_EPOCH;
break;
case '!':
jd = getNumber('!') / this.TICKS_PER_DAY + this.TICKS_EPOCH;
break;
case '*':
iValue = value.length;
break;
case '\'':
if (doubled('\'')) {
checkLiteral();
}
else {
literal = true;
}
break;
default:
checkLiteral();
}
}
}
if (iValue < value.length) {
throw $.calendars.local.unexpectedText || $.calendars.regionalOptions[''].unexpectedText;
}
if (year === -1) {
year = this.today().year();
}
else if (year < 100 && shortYear) {
year += (shortYearCutoff === -1 ? 1900 : this.today().year() -
this.today().year() % 100 - (year <= shortYearCutoff ? 0 : 100));
}
if (doy > -1) {
month = 1;
day = doy;
for (var dim = this.daysInMonth(year, month); day > dim; dim = this.daysInMonth(year, month)) {
month++;
day -= dim;
}
}
return (jd > -1 ? this.fromJD(jd) : this.newDate(year, month, day));
},
/** A date may be specified as an exact value or a relative one.
Found in the <code>jquery.calendars.plus.js</code> module.
@memberof BaseCalendar
@param {CDate|number|string} dateSpec The date as an object or string in the given format or
an offset - numeric days from today, or string amounts and periods, e.g. '+1m +2w'.
@param {CDate} defaultDate The date to use if no other supplied, may be <code>null</code>.
@param {CDate} [currentDate=null] The current date as a possible basis for relative dates,
if <code>null</code> today is used.
@param {string} [dateFormat] The expected date format -
see {@linkcode BaseCalendar.formatDate|formatDate}. Use '' for the calendar default format.
@param {object} [settings] Additional options whose attributes include:
@param {number} [settings.shortYearCutoff] The cutoff year for determining the century.
@param {string[]} [settings.dayNamesShort] Abbreviated names of the days from day 0 (Sunday).
@param {string[]} [settings.dayNames] Names of the days from day 0 (Sunday).
@param {string[]} [settings.monthNamesShort] Abbreviated names of the months.
@param {string[]} [settings.monthNames] Names of the months.
@return {CDate} The decoded date. */
determineDate: function(dateSpec, defaultDate, currentDate, dateFormat, settings) {
if (currentDate && typeof currentDate !== 'object') {
settings = dateFormat;
dateFormat = currentDate;
currentDate = null;
}
if (typeof dateFormat !== 'string') {
settings = dateFormat;
dateFormat = '';
}
var calendar = this;
var offsetString = function(offset) {
try {
return calendar.parseDate(dateFormat, offset, settings);
}
catch (e) {
// Ignore
}
offset = offset.toLowerCase();
var date = (offset.match(/^c/) && currentDate ?
currentDate.newDate() : null) || calendar.today();
var pattern = /([+-]?[0-9]+)\s*(d|w|m|y)?/g;
var matches = pattern.exec(offset);
while (matches) {
date.add(parseInt(matches[1], 10), matches[2] || 'd');
matches = pattern.exec(offset);
}
return date;
};
defaultDate = (defaultDate ? defaultDate.newDate() : null);
dateSpec = (typeof dateSpec === 'undefined' || dateSpec === null ? defaultDate :
(typeof dateSpec === 'string' ? offsetString(dateSpec) : (typeof dateSpec === 'number' ?
(isNaN(dateSpec) || dateSpec === Infinity || dateSpec === -Infinity ? defaultDate :
calendar.today().add(dateSpec, 'd')) : calendar.newDate(dateSpec))));
return dateSpec;
}
});
})(jQuery);

View File

@@ -0,0 +1,389 @@
/* globals JQClass */
/*! Simple JavaScript Inheritance
* By John Resig http://ejohn.org/
* MIT Licensed.
*/
// Inspired by base2 and Prototype
import $ from "@/shared/common/jquery-global";
(function(){
'use strict';
var initializing = false;
// The base JQClass implementation (does nothing)
window.JQClass = function(){};
// Collection of derived classes
JQClass.classes = {};
// Create a new JQClass that inherits from this class
JQClass.extend = function extender(prop) {
var base = this.prototype;
// Instantiate a base class (but only create the instance, don't run the init constructor)
initializing = true;
var prototype = new this();
initializing = false;
// Copy the properties over onto the new prototype
for (var name in prop) { // jshint loopfunc:true
// Check if we're overwriting an existing function
if (typeof prop[name] === 'function' && typeof base[name] === 'function') {
prototype[name] = (function (name, fn) {
return function () {
var __super = this._super;
// Add a new ._super() method that is the same method but on the super-class
this._super = function (args) {
return base[name].apply(this, args || []);
};
var ret = fn.apply(this, arguments);
// The method only needs to be bound temporarily, so we remove it when we're done executing
this._super = __super;
return ret;
};
})(name, prop[name]);
// Check if we're overwriting existing default options.
} else if (typeof prop[name] === 'object' && typeof base[name] === 'object' && name === 'defaultOptions') {
var obj1 = base[name];
var obj2 = prop[name];
var obj3 = {};
var key;
for (key in obj1) { // jshint forin:false
obj3[key] = obj1[key];
}
for (key in obj2) { // jshint forin:false
obj3[key] = obj2[key];
}
prototype[name] = obj3;
} else {
prototype[name] = prop[name];
}
}
// The dummy class constructor
function JQClass() {
// All construction is actually done in the init method
if (!initializing && this._init) {
this._init.apply(this, arguments);
}
}
// Populate our constructed prototype object
JQClass.prototype = prototype;
// Enforce the constructor to be what we expect
JQClass.prototype.constructor = JQClass;
// And make this class extendable
JQClass.extend = extender;
return JQClass;
};
})();
/*! Abstract base class for collection plugins v1.0.2.
Written by Keith Wood (wood.keith{at}optusnet.com.au) December 2013.
Licensed under the MIT license (http://keith-wood.name/licence.html). */
(function($) { // Ensure $, encapsulate
'use strict';
/** <p>Abstract base class for collection plugins v1.0.2.</p>
<p>Written by Keith Wood (wood.keith{at}optusnet.com.au) December 2013.</p>
<p>Licensed under the MIT license (http://keith-wood.name/licence.html).</p>
<p>Use {@link $.JQPlugin.createPlugin} to create new plugins using this framework.</p>
<p>This base class provides common functionality such as:</p>
<ul>
<li>Creates jQuery bridge - allowing you to invoke your plugin on a collection of elements.</li>
<li>Handles initialisation including reading settings from metadata -
an instance object is attached to the affected element(s) containing all the necessary data.</li>
<li>Handles option retrieval and update - options can be set through default values,
through inline metadata, or through instantiation settings.<br>
Metadata is specified as an attribute on the element:
<code>data-&lt;pluginName>="&lt;option name>: '&lt;value>', ..."</code>.
Dates should be specified as strings in this format: <code>'new Date(y, m-1, d)'</code>.</li>
<li>Handles method calling - inner functions starting with '_'are inaccessible,
whereas others can be called via <code>$(selector).pluginName('functionName')</code>.</li>
<li>Handles plugin destruction - removing all trace of the plugin.</li>
</ul>
@module JQPlugin
@abstract */
JQClass.classes.JQPlugin = JQClass.extend({
/** Name to identify this plugin.
@example name: 'tabs' */
name: 'plugin',
/** Default options for instances of this plugin (default: {}).
@example defaultOptions: {
selectedClass: 'selected',
triggers: 'click'
} */
defaultOptions: {},
/** Options dependent on the locale.
Indexed by language and (optional) country code, with '' denoting the default language (English/US).
Normally additional languages would be provided as separate files to all them to be included as needed.
@example regionalOptions: {
'': {
greeting: 'Hi'
}
} */
regionalOptions: {},
/** Whether or not a deep merge should be performed when accumulating options.
The default is <code>true</code> but can be overridden in a sub-class. */
deepMerge: true,
/** Retrieve a marker class for affected elements.
In the format: <code>is-&lt;pluginName&gt;</code>.
@protected
@return {string} The marker class. */
_getMarker: function() {
return 'is-' + this.name;
},
/** Initialise the plugin.
Create the jQuery bridge - plugin name <code>xyz</code>
produces singleton <code>$.xyz</code> and collection function <code>$.fn.xyz</code>.
@protected */
_init: function() {
// Apply default localisations
$.extend(this.defaultOptions, (this.regionalOptions && this.regionalOptions['']) || {});
// Camel-case the name
var jqName = camelCase(this.name);
// Expose jQuery singleton manager
$[jqName] = this;
// Expose jQuery collection plugin
$.fn[jqName] = function(options) {
var otherArgs = Array.prototype.slice.call(arguments, 1);
var inst = this;
var returnValue = this;
this.each(function () {
if (typeof options === 'string') {
if (options[0] === '_' || !$[jqName][options]) {
throw 'Unknown method: ' + options;
}
var methodValue = $[jqName][options].apply($[jqName], [this].concat(otherArgs));
if (methodValue !== inst && methodValue !== undefined) {
returnValue = methodValue;
return false;
}
} else {
$[jqName]._attach(this, options);
}
});
return returnValue;
};
},
/** Set default options for all subsequent instances.
@param {object} options The new default options.
@example $.pluginName.setDefaults({name: value, ...}) */
setDefaults: function(options) {
$.extend(this.defaultOptions, options || {});
},
/** Initialise an element. Called internally only.
Adds an instance object as data named for the plugin.
Override {@linkcode module:JQPlugin~_postAttach|_postAttach} for plugin-specific processing.
@private
@param {Element} elem The element to enhance.
@param {object} options Overriding settings. */
_attach: function(elem, options) {
elem = $(elem);
if (elem.hasClass(this._getMarker())) {
return;
}
elem.addClass(this._getMarker());
options = $.extend(this.deepMerge, {}, this.defaultOptions, this._getMetadata(elem), options || {});
var inst = $.extend({name: this.name, elem: elem, options: options}, this._instSettings(elem, options));
elem.data(this.name, inst); // Save instance against element
this._postAttach(elem, inst);
this.option(elem, options);
},
/** Retrieve additional instance settings.
Override this in a sub-class to provide extra settings.
These are added directly to the instance object.
Default attributes of an instance object are shown as properties below:
@protected
@param {jQuery} elem The current jQuery element.
@param {object} options The instance options.
@return {object} Any extra instance values.
@property {Element} elem The element to which this instance applies.
@property {string} name The name of this plugin.
@property {object} options The accumulated options for this instance.
@example _instSettings: function(elem, options) {
return {nav: elem.find(options.navSelector)};
} */
_instSettings: function(elem, options) { // jshint unused:false
return {};
},
/** Plugin specific post initialisation.
Override this in a sub-class to perform extra activities.
This is where you would implement your plugin's main functionality.
@protected
@param {jQuery} elem The current jQuery element.
@param {object} inst The instance settings.
@example _postAttach: function(elem, inst) {
elem.on('click.' + this.name, function() {
...
});
} */
_postAttach: function(elem, inst) { // jshint unused:false
},
/** Retrieve metadata configuration from the element.
Metadata is specified as an attribute:
<code>data-&lt;pluginName>="&lt;option name>: '&lt;value>', ..."</code>.
Dates should be specified as strings in this format: <code>'new Date(y, m-1, d)'</code>.
@private
@param {jQuery} elem The source element.
@return {object} The inline configuration or {}. */
_getMetadata: function(elem) {
try {
var data = elem.data(this.name.toLowerCase()) || '';
data = data.replace(/(\\?)'/g, function(e, t) {
return t ? '\'' : '"';
}).replace(/([a-zA-Z0-9]+):/g, function(match, group, i) {
var count = data.substring(0, i).match(/"/g); // Handle embedded ':'
return (!count || count.length % 2 === 0 ? '"' + group + '":' : group + ':');
}).replace(/\\:/g, ':');
data = $.parseJSON('{' + data + '}');
for (var key in data) {
if (data.hasOwnProperty(key)) {
var value = data[key];
if (typeof value === 'string' && value.match(/^new Date\(([-0-9,\s]*)\)$/)) { // Convert dates
data[key] = eval(value); // jshint ignore:line
}
}
}
return data;
}
catch (e) {
return {};
}
},
/** Retrieve the instance data for element.
@protected
@param {Element} elem The source element.
@return {object} The instance data or <code>{}</code> if none. */
_getInst: function(elem) {
return $(elem).data(this.name) || {};
},
/** Retrieve or reconfigure the settings for a plugin.
If new settings are provided they are applied to the instance options.
If an option name only is provided the value of that option is returned.
If no name or value is provided, all options are returned.
Override {@linkcode module:JQPlugin~_optionsChanged|_optionsChanged}
for plugin-specific processing when option values change.
@param {Element} elem The source element.
@param {object|string} [name] The collection of new option values or the name of a single option.
@param {any} [value] The value for a single named option.
@return {any|object} If retrieving a single value or all options.
@example $(selector).plugin('option', 'name', value) // Set one option
$(selector).plugin('option', {name: value, ...}) // Set multiple options
var value = $(selector).plugin('option', 'name') // Get one option
var options = $(selector).plugin('option') // Get all options */
option: function(elem, name, value) {
elem = $(elem);
var inst = elem.data(this.name);
var options = name || {};
if (!name || (typeof name === 'string' && typeof value === 'undefined')) {
options = (inst || {}).options;
return (options && name ? options[name] : options);
}
if (!elem.hasClass(this._getMarker())) {
return;
}
if (typeof name === 'string') {
options = {};
options[name] = value;
}
this._optionsChanged(elem, inst, options);
$.extend(inst.options, options);
},
/** Plugin specific options processing.
Old value available in <code>inst.options[name]</code>, new value in <code>options[name]</code>.
Override this in a sub-class to perform extra activities.
@protected
@param {jQuery} elem The current jQuery element.
@param {object} inst The instance settings.
@param {object} options The new options.
@example _optionsChanged: function(elem, inst, options) {
if (options.name != inst.options.name) {
elem.removeClass(inst.options.name).addClass(options.name);
}
} */
_optionsChanged: function(elem, inst, options) { // jshint unused:false
},
/** Remove all trace of the plugin.
Override {@linkcode module:JQPlugin~_preDestroy|_preDestroy} for plugin-specific processing.
@param {Element} elem The source element.
@example $(selector).plugin('destroy') */
destroy: function(elem) {
elem = $(elem);
if (!elem.hasClass(this._getMarker())) {
return;
}
this._preDestroy(elem, this._getInst(elem));
elem.removeData(this.name).removeClass(this._getMarker());
},
/** Plugin specific pre destruction.
It is invoked as part of the {@linkcode module:JQPlugin~destroy|destroy} processing.
Override this in a sub-class to perform extra activities and undo everything that was
done in the {@linkcode module:JQPlugin~_postAttach|_postAttach} or
{@linkcode module:JQPlugin~_optionsChanged|_optionsChanged} functions.
@protected
@param {jQuery} elem The current jQuery element.
@param {object} inst The instance settings.
@example _preDestroy: function(elem, inst) {
elem.off('.' + this.name);
} */
_preDestroy: function(elem, inst) { // jshint unused:false
}
});
/** Convert names from hyphenated to camel-case.
@private
@param {string} value The original hyphenated name.
@return {string} The camel-case version. */
function camelCase(name) {
return name.replace(/-([a-z])/g, function(match, group) {
return group.toUpperCase();
});
}
/** Expose the plugin base.
@namespace $.JQPlugin */
$.JQPlugin = {
/** Create a new collection plugin.
@memberof $.JQPlugin
@param {string} [superClass='JQPlugin'] The name of the parent class to inherit from.
@param {object} overrides The property/function overrides for the new class.
See {@link module:JQPlugin|JQPlugin} for the base functionality.
@example $.JQPlugin.createPlugin({ // Define the plugin
name: 'tabs',
defaultOptions: {selectedClass: 'selected'},
_initSettings: function(elem, options) { return {...}; },
_postAttach: function(elem, inst) { ... }
});
$('selector').tabs(); // And instantiate it */
createPlugin: function(superClass, overrides) {
if (typeof superClass === 'object') {
overrides = superClass;
superClass = 'JQPlugin';
}
superClass = camelCase(superClass);
var className = camelCase(overrides.name);
JQClass.classes[className] = JQClass.classes[superClass].extend(overrides);
new JQClass.classes[className](); // jshint ignore:line
}
};
})(jQuery);

View File

@@ -0,0 +1,21 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
export type LocalizedText = {
am?: string;
en?: string;
};
export const useLocalizedName = (language?: string) => {
const { i18n } = useTranslation();
const currentLanguage = language || i18n.language;
return useCallback(
(name?: LocalizedText): string => {
if (!name) return "";
const am = name.am?.trim() ?? "";
const en = name.en?.trim() ?? "";
return (currentLanguage.startsWith("am") ? am || en : en || am) || "";
},
[currentLanguage],
);
};

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { Input } from "@/shared/common/ui/input"; // your Input component
interface FaydaInputProps {
faydaValue: string;
setFaydaValue: (value: string) => void; // make sure it's not optional
}
export const FaydaInput: React.FC<FaydaInputProps> = ({
faydaValue,
setFaydaValue,
}) => {
return (
<div className="space-y-3">
<label className="block">
<span className="font-medium">Fayda ID</span>
<Input
type="text"
value={faydaValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setFaydaValue(e.target.value)
}
placeholder="Enter Fayda ID"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</label>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { Progress } from "@/shared/common/ui/progress";
interface LoadingOverlayProps {
progress?: number;
text?: string;
}
export function LoadingOverlay({
progress = 50,
text = "Processing...",
}: LoadingOverlayProps) {
return (
<div className="fixed inset-0 bg-primary-300 bg-opacity-50 z-50 flex flex-col items-center justify-center space-y-4">
<div className="text-white text-lg font-semibold animate-pulse">
{text}
</div>
<Progress value={progress} className="w-3/4 md:w-1/3 h-3 rounded-full" />
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { FC } from "react";
// @ts-ignore - Module types not available
import * as SliderPrimitive from "@radix-ui/react-slider";
interface SliderUIProps {
id?: string;
label: string;
description?: string;
value: number[];
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
required?: boolean;
disabled?: boolean;
}
export const SliderUI: FC<SliderUIProps> = ({
id,
label,
description,
value,
onChange,
min = 0,
max = 100,
step = 1,
required = false,
disabled = false,
}) => {
return (
<div className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-gray-500">{description}</p>}
<SliderPrimitive.Root
id={id}
value={value}
onValueChange={(val: number[]) => onChange(val[0])}
min={min}
max={max}
step={step}
disabled={disabled}
className="relative flex w-full touch-none select-none items-center h-5">
<SliderPrimitive.Track className="relative bg-gray-200 flex-1 h-1 rounded-full">
<SliderPrimitive.Range className="absolute bg-blue-500 h-full rounded-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block w-5 h-5 bg-white border border-gray-300 rounded-full shadow" />
</SliderPrimitive.Root>
<div className="text-sm text-gray-600 text-center">{value}</div>
</div>
);
};

View File

@@ -0,0 +1,25 @@
// src/shared/common/ui/text.tsx
import React from "react";
type TextProps = React.HTMLAttributes<HTMLSpanElement> & {
fw?: number | string;
color?: string;
size?: string | number;
};
export const Text: React.FC<TextProps> = ({
fw,
color,
size,
style,
children,
...props
}) => {
return (
<span
style={{ fontWeight: fw, color, fontSize: size, ...style }}
{...props}>
{children}
</span>
);
};

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/shared/lib/utils";
import { buttonVariants } from "@/shared/common/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/super-admin/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/super-admin/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,62 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
//the status edit
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
//draft and forward
default:
"bg-blue-100 text-blue-800 text-sm font-medium me-2 px-3 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300",
secondary:
"bg-indigo-100 text-indigo-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-indigo-900 dark:text-indigo-300",
destructive:
"bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
info: "border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90",
purple:
"bg-purple-100 text-purple-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-purple-900 dark:text-purple-300",
warning:
"bg-pink-100 text-pink-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-pink-900 dark:text-pink-300",
//approved
success:
"bg-green-100 text-green-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300",
danger:
"bg-red-100 text-red-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-red-900 dark:text-red-300",
danger2:
"bg-yellow-100 text-yellow-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300",
forward:
"bg-orange-100 text-orange-800 text-sm font-medium me-2 px-3 py-0.5 rounded-full dark:bg-orange-900 dark:text-orange-300",
dispatched:
"bg-green-100 text-green-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,161 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-muted-foreground underline-offset-13 hover:underline hover:text-primary",
linkActive: "text-primary-700 underline-offset-13 underline -mt-2",
addRecordBtn:
"bg-primary text-white size-35 shadow-xs hover:bg-primary-700 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
viewDetail:
"bg-transparent border border-primary text-primary text-base font-normal me-2 !px-2.5 py-0.5 rounded-full scale-90 origin-center hover:bg-primary/10 transition-colors dark:bg-primary/20 dark:border-primary dark:text-primary-foreground leading-tight whitespace-nowrap",
incomingmenu:
"bg-transparent border border-primary text-primary text-md font-normal me-2 !px-4 py-0.5 rounded-md w-[170px] scale-90 origin-center hover:bg-primary/10 transition-colors dark:bg-primary/20 dark:border-primary dark:text-primary-foreground leading-tight whitespace-nowrap",
incomingmenu1:
"bg-transparent border border-gray-500 text-gray-500 text-md font-normal me-2 !px-4 py-0.5 rounded-md w-[170px] scale-90 origin-center hover:bg-gray-500/10 transition-colors dark:border-gray-300 dark:text-gray-300 leading-tight whitespace-nowrap",
actionGreen:
"bg-emerald-50/90 dark:bg-emerald-950/20 border border-emerald-200 dark:border-emerald-900/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40 hover:text-emerald-800 dark:hover:text-emerald-200 hover:border-emerald-300 dark:hover:border-emerald-800/60 focus-visible:ring-emerald-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
actionLightGreen:
"bg-green-50/50 dark:bg-green-950/10 border border-green-100 dark:border-green-900/20 text-green-600 dark:text-green-400 hover:bg-green-100/50 dark:hover:bg-green-950/30 hover:text-green-700 dark:hover:text-green-300 hover:border-green-200 dark:hover:border-green-800/40 focus-visible:ring-green-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
actionRed:
"bg-rose-50/90 dark:bg-rose-950/20 border border-rose-200 dark:border-rose-900/40 text-rose-700 dark:text-rose-300 hover:bg-rose-100 dark:hover:bg-rose-950/40 hover:text-rose-800 dark:hover:text-rose-200 hover:border-rose-300 dark:hover:border-rose-800/60 focus-visible:ring-rose-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
actionYellow:
"bg-amber-50/90 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/40 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40 hover:text-amber-800 dark:hover:text-amber-200 hover:border-amber-300 dark:hover:border-amber-800/60 focus-visible:ring-amber-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
actionBlue:
"bg-blue-50/90 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900/40 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-950/40 hover:text-blue-800 dark:hover:text-blue-200 hover:border-blue-300 dark:hover:border-blue-800/60 focus-visible:ring-blue-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
actionPurple:
"bg-purple-50/90 dark:bg-purple-950/20 border border-purple-200 dark:border-purple-900/40 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-950/40 hover:text-purple-800 dark:hover:text-purple-200 hover:border-purple-300 dark:hover:border-purple-800/60 focus-visible:ring-purple-500 rounded-full h-9 px-4 flex items-center gap-2 font-medium shadow-xs hover:shadow-md transition-all duration-300",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, type, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
// When `asChild` is used, Radix will attach the ref to the child via Slot.
ref={ref as any}
className={cn(
buttonVariants({ variant, size, className }),
"cursor-pointer",
)}
{...(asChild ? {} : { type: type ?? "button" })}
{...props}
/>
);
},
);
Button.displayName = "Button";
export function getActionButtonVariant(actionName: string): any {
const normalized = actionName.trim().toLowerCase();
if (
normalized.includes("approve") ||
normalized.includes("sign") ||
normalized.includes("reply") ||
normalized === "accept"
) {
return "actionGreen";
}
if (
normalized.includes("accept and forward") ||
normalized.includes("accept & forward")
) {
return "actionLightGreen";
}
if (normalized.includes("delete") || normalized.includes("reject")) {
return "actionRed";
}
if (normalized.includes("forward")) {
return "actionPurple";
}
if (
normalized.includes("adjustment") ||
normalized.includes("adjust") ||
normalized.includes("secure letter") ||
normalized.includes("make urgent") ||
normalized.includes("urgent") ||
normalized.includes("assign") ||
normalized.includes("reassign") ||
normalized.includes("under review") ||
normalized.includes("return")
) {
return "actionYellow";
}
if (normalized.includes("edit") || normalized.includes("comment")) {
return "actionBlue";
}
if (
normalized.includes("send") ||
normalized.includes("dispatch") ||
normalized.includes("share")
) {
return "actionPurple";
}
// Custom keyword heuristics
if (
normalized.includes("remove") ||
normalized.includes("cancel") ||
normalized.includes("fail") ||
normalized.includes("discard")
) {
return "actionRed";
}
if (
normalized.includes("warn") ||
normalized.includes("caution") ||
normalized.includes("alert") ||
normalized.includes("pending")
) {
return "actionYellow";
}
if (
normalized.includes("success") ||
normalized.includes("complete") ||
normalized.includes("done") ||
normalized.includes("save")
) {
return "actionGreen";
}
return "actionBlue"; // Informational fallback
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/style.css";
import { cn } from "@/shared/lib/utils";
import { buttonVariants } from "@/shared/common/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
{...props}
className={cn(
"rdp w-full rounded-xl border border-slate-200 bg-white p-3 shadow-[0_10px_30px_-20px_rgba(15,23,42,0.5)] dark:border-slate-700 dark:bg-slate-900",
className,
)}
classNames={{
months: "rdp-months flex flex-col gap-4 sm:flex-row sm:gap-6",
month: "rdp-month space-y-4",
caption: "rdp-caption relative flex items-center justify-center pt-1",
caption_label:
"rdp-caption_label text-sm font-semibold tracking-wide text-slate-800 dark:text-slate-100",
nav: "rdp-nav flex items-center gap-1",
nav_button: cn(
"rdp-nav_button",
buttonVariants({ variant: "ghost", size: "icon" }),
"h-8 w-8 rounded-lg text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100",
),
nav_button_previous: "rdp-nav_button_previous absolute left-0",
nav_button_next: "rdp-nav_button_next absolute right-0",
table: "rdp-table w-full border-collapse space-y-1",
head_row: "rdp-head_row flex w-full justify-between",
head_cell:
"rdp-head_cell text-slate-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-slate-400 uppercase tracking-wide text-center pt-1 mb-2 whitespace-nowrap",
row: "rdp-row flex w-full justify-between mt-1",
cell: cn(
"rdp-cell relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-10",
props.mode === "range"
? "[&:has([aria-selected].day-range-end)]:rounded-r-lg [&:has([aria-selected].day-range-start)]:rounded-l-lg [&:has([aria-selected].day-range-middle)]:bg-primary-50 dark:[&:has([aria-selected].day-range-middle)]:bg-primary-900/30"
: "",
),
day: cn(
"rdp-day",
buttonVariants({ variant: "ghost" }),
"h-9 w-9 rounded-lg p-0 font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-primary-500/30 aria-selected:opacity-100 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white",
),
day_selected:
"rdp-day_selected bg-primary-600 text-white hover:bg-primary-600 hover:text-white focus:bg-primary-600 focus:text-white dark:bg-primary-500 dark:hover:bg-primary-500",
day_today:
"rdp-day_today border border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-900/30 dark:text-primary-300",
day_outside:
"rdp-day_outside text-slate-400 opacity-60 aria-selected:bg-primary-100 aria-selected:text-primary-700 dark:text-slate-600 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-300",
day_disabled: "rdp-day_disabled text-slate-300 opacity-50 dark:text-slate-600",
day_range_middle:
"rdp-day_range_middle aria-selected:bg-primary-100 aria-selected:text-primary-800 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-200",
day_range_start:
"rdp-day_range_start day-range-start aria-selected:bg-primary-600 aria-selected:text-white",
day_range_end:
"rdp-day_range_end day-range-end aria-selected:bg-primary-600 aria-selected:text-white",
day_hidden: "rdp-day_hidden invisible",
...classNames,
}}
// Removed unsupported 'Chevron' component override
/>
);
}
export { Calendar };

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,355 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/shared/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: Omit<React.ComponentProps<typeof RechartsPrimitive.Tooltip>, "payload"> &
React.ComponentProps<"div"> & {
payload?: Array<any>;
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
label?: React.ReactNode;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload as any)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item as any, index, item.payload as any)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || (typeof item.name === "string" ? item.name : (item.name as any)?.en || (item.name as any)?.am || "Item")}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
{
payload?: { dataKey?: string; value?: string; color?: string }[];
verticalAlign?: "top" | "bottom" | "middle";
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className={cn(
"h-2 w-2 shrink-0 rounded-[2px]",
item.color ? `chart-legend-color--${item.color.replace("#", "")}` : ""
)}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
// Define variants using CVA
const checkboxVariants = cva(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "",
//soft: "rounded-full bg-primary/10 border-primary text-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=checked]:border-primary",
subtle: "border-muted bg-muted/20",
soft: `
size-5 rounded-full border-2 border-primary bg-transparent
data-[state=checked]:bg-primary
data-[state=checked]:border-primary
transition-colors
`,
},
size: {
sm: "size-3",
md: "size-4",
lg: "size-5",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
}
);
function Checkbox({
className,
variant = "default",
size = "md",
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root> &
VariantProps<typeof checkboxVariants>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(checkboxVariants({ variant, size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox, checkboxVariants };

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,206 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3 dark:border-gray-600"
>
<SearchIcon className="size-4 shrink-0 opacity-50 dark:text-gray-400" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const listElement = listRef.current;
if (!listElement) return;
const handleWheel = (e: WheelEvent) => {
// Allow scrolling within the list
const { scrollTop, scrollHeight, clientHeight } = listElement;
const isScrollingUp = e.deltaY < 0;
const isScrollingDown = e.deltaY > 0;
const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
// Only prevent default if we're not at the boundaries
if ((isScrollingUp && !isAtTop) || (isScrollingDown && !isAtBottom)) {
e.stopPropagation();
}
};
listElement.addEventListener("wheel", handleWheel, { passive: false });
return () => {
listElement.removeEventListener("wheel", handleWheel);
};
}, []);
return (
<CommandPrimitive.List
ref={listRef}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain custom-scrollbar",
className
)}
style={{
scrollbarWidth: "thin",
}}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,66 @@
import { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className="bg-gray-50 dark:bg-gray-800 text-base text-gray-500 dark:text-gray-400">{title}</div>;
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent dark:data-[state=open]:bg-gray-700"
>
<span className="bg-gray-50 dark:bg-gray-800 text-base text-gray-500 dark:text-gray-400">{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown className="h-4 w-4 dark:text-gray-400" />
) : column.getIsSorted() === "asc" ? (
<ArrowUp className="h-4 w-4 dark:text-gray-400" />
) : (
<ChevronsUpDown className="h-4 w-4 dark:text-gray-400" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="dark:bg-gray-800 dark:border-gray-700">
<DropdownMenuItem onClick={() => column.toggleSorting(false)} className="dark:text-gray-200 dark:hover:bg-gray-700">
<ArrowUp className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)} className="dark:text-gray-200 dark:hover:bg-gray-700">
<ArrowDown className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator className="dark:bg-gray-700" />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)} className="dark:text-gray-200 dark:hover:bg-gray-700">
<EyeOff className="h-3.5 w-3.5 text-muted-foreground/70 dark:text-gray-400" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { Table } from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import React from "react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
pageCount: number;
}
export function DataTablePagination<TData>({
table,
pageCount
}: DataTablePaginationProps<TData>) {
const handlePageSizeChange = React.useCallback(
(value: string) => {
table.setPageSize(Number(value));
},
[table]
);
return (
<div className="flex items-center justify-end px-2 my-4">
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => {
table.setPageIndex(0);
}}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to first page</span>
<ChevronsLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
table.previousPage();
}}
disabled={!table.getCanPreviousPage()}>
<span className="sr-only">Go to previous page</span>
<ChevronLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
table.nextPage();
}}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to next page</span>
<ChevronRight />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => {
const lastPageIndex = table.getPageCount() - 1;
table.setPageIndex(lastPageIndex);
}}
disabled={!table.getCanNextPage()}>
<span className="sr-only">Go to last page</span>
<ChevronsRight />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { Table } from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import React from "react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
pageCount: number;
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-2">
<div className="flex items-center space-x-2">
<p className="text-xs sm:text-sm font-medium whitespace-nowrap">
Rows per page
</p>
<Select
value={`${table.getState().pagination?.pageSize ?? 10}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-9 sm:h-8 w-[70px] min-w-[70px] text-sm">
<SelectValue
placeholder={table.getState().pagination?.pageSize ?? 10}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-full sm:w-auto items-center justify-between sm:justify-end gap-2 sm:gap-4">
<div className="flex items-center justify-center text-xs sm:text-sm font-medium whitespace-nowrap">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-1 sm:space-x-2">
<Button
variant="outline"
className="h-9 w-9 p-0 sm:hidden"
onClick={() => {
table.setPageIndex(0);
}}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-9 sm:h-8 w-9 sm:w-8 p-0 sm:flex"
onClick={() => {
table.setPageIndex(0);
}}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-9 sm:h-8 w-9 sm:w-8 p-0"
onClick={() => {
table.previousPage();
}}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-9 sm:h-8 w-9 sm:w-8 p-0"
onClick={() => {
table.nextPage();
}}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-9 w-9 p-0 sm:hidden"
onClick={() => {
const lastPageIndex = table.getPageCount() - 1;
table.setPageIndex(lastPageIndex);
}}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-9 sm:h-8 w-9 sm:w-8 p-0 sm:flex"
onClick={() => {
const lastPageIndex = table.getPageCount() - 1;
table.setPageIndex(lastPageIndex);
}}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "@/shared/lib/utils";
export const Drawer = Dialog.Root;
export const DrawerTrigger = Dialog.Trigger;
export const DrawerContent = ({
children,
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content
className={cn(
"fixed right-0 top-0 h-full w-[400px] bg-white p-6 shadow-lg",
className
)}
{...props}>
{children}
</Dialog.Content>
</Dialog.Portal>
);
export const DrawerHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div className={cn("mb-4", className)} {...props}>
{children}
</div>
);
export const DrawerTitle = Dialog.Title;
export const DrawerDescription = Dialog.Description;
export const DrawerFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div className={cn("mt-4 flex justify-end gap-2", className)} {...props}>
{children}
</div>
);
export const DrawerClose = Dialog.Close;

View File

@@ -0,0 +1,255 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -0,0 +1,39 @@
import { ImageIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { Card, CardDescription, CardTitle } from "@/shared/common/ui/card";
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
title: string;
description?: string;
action?: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}
export function EmptyCard({
title,
description,
icon: Icon = ImageIcon,
action,
className,
...props
}: EmptyCardProps) {
return (
<Card
className={cn(
"flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16",
className
)}
{...props}
>
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
<Icon className="size-8 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{action ? action : null}
</Card>
);
}

View File

@@ -0,0 +1,430 @@
"use client";
import * as React from "react";
import { FileText, Upload, X } from "lucide-react";
import Dropzone, {
type DropzoneProps,
type FileRejection,
} from "react-dropzone";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { Progress } from "@/shared/common/ui/progress";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import { useControllableState } from "./hooks/use-controllable-state";
import { cn } from "@/shared/lib/utils";
import { formatBytes } from "./utils";
import { useTranslation } from "react-i18next";
import { t } from "i18next";
import { ClientSideValidator } from "@/shared/services/validation/ClientSideValidator";
import { FileUploadValidator } from "@/shared/services/validation/FileUploadValidator";
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[];
/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange: (files: File[]) => void | null;
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>;
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>;
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "image/*": [] }
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps["accept"];
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps["maxSize"];
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps["maxFiles"];
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean;
canUploadIndividual?: boolean;
uploadIndividualFun?: () => void;
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean;
}
export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
onUpload,
progresses,
accept = {
"/": [],
},
maxSize = 1024 * 1024 * 200,
maxFileCount = 4,
multiple = false,
disabled = false,
className,
canUploadIndividual = false,
uploadIndividualFun,
...dropzoneProps
} = props;
const { t } = useTranslation();
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange,
});
const validatorRef = React.useRef(new ClientSideValidator());
const onDrop = React.useCallback(
async (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
toast.error(
`${t("PDF.cannotUploadMoreThan")} ${maxFileCount} ${t("PDF.files")}`
);
return;
}
// Validate files using comprehensive validator
const validatedFiles: File[] = [];
const invalidFiles: string[] = [];
for (const file of acceptedFiles) {
const allowedMimeTypes = accept
? Object.keys(accept)
.map((mime) => mime.trim().toLowerCase())
.filter(
(mime) =>
mime.includes("/") &&
mime !== "/" &&
mime !== "*" &&
mime !== "*/*"
)
: undefined;
const allowedExtensions = accept
? Object.values(accept).flatMap((extensions) =>
(extensions || []).map((ext) =>
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`
)
)
: undefined;
// Basic validation (size, extension, filename, MIME type)
const basicValidation = FileUploadValidator.validateFile(file, {
maxSizeMB: maxSize ? maxSize / (1024 * 1024) : undefined,
allowedMimeTypes:
file.type && allowedMimeTypes?.length ? allowedMimeTypes : undefined,
allowedExtensions:
allowedExtensions && allowedExtensions.length > 0
? allowedExtensions
: undefined,
});
if (!basicValidation.isValid) {
invalidFiles.push(`${file.name}: ${basicValidation.error}`);
continue;
}
// Client-side magic byte validation
const clientValidation = await validatorRef.current.validateUpload(
file,
file.type,
allowedMimeTypes || []
);
if (!clientValidation.isValid) {
invalidFiles.push(`${file.name}: ${clientValidation.error}`);
continue;
}
validatedFiles.push(file);
}
// Show errors for invalid files
if (invalidFiles.length > 0) {
invalidFiles.forEach((error) => {
toast.error(error);
});
}
const newFiles = validatedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
);
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
setFiles(updatedFiles);
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`${t("PDF.file")} ${file.name} ${t("PDF.wasRejected")}`);
});
}
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFileCount
) {
const target =
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
toast.promise(onUpload(updatedFiles), {
loading: `${t("PDF.uploading")} ${target}...`,
success: () => {
setFiles([]);
return `${target} ${t("PDF.uploaded")}`;
},
error: `${t("PDF.failedToUpload")} ${target}`,
});
}
},
[files, maxFileCount, multiple, onUpload, setFiles, t, maxSize, accept]
);
function onRemove(index: number) {
if (!files) return;
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
onValueChange?.(newFiles);
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return;
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
{onValueChange !== null && (
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
"group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"dark:bg-gray-900/50 dark:border-gray-600 dark:hover:bg-gray-800/60",
isDragActive && "border-muted-foreground/50 dark:border-gray-500",
isDisabled && "pointer-events-none opacity-60",
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed border-muted-foreground/50 p-3 dark:border-gray-500">
<Upload
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="font-medium text-muted-foreground">
{t("PDF.dropFilesHere")}
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed border-muted-foreground/50 p-3 dark:border-gray-500">
<Upload
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-muted-foreground">
{t("PDF.dragDropInstruction")}
</p>
<p className="text-sm text-muted-foreground/70">
{t("PDF.youCanUpload")}
{maxFileCount > 1
? ` ${
maxFileCount === Infinity
? "multiple"
: maxFileCount
}
${t("PDF.filesUpTo")} ${formatBytes(maxSize)} )`
: ` ${t("PDF.aFileWith")} ${formatBytes(maxSize)}`}
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
)}
{files?.length ? (
<ScrollArea className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
canUploadIndividual={canUploadIndividual}
uploadIndividualFun={uploadIndividualFun || (() => {})}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
);
}
interface FileCardProps {
file: File;
onRemove: () => void;
progress?: number;
canUploadIndividual?: boolean;
uploadIndividualFun: () => void;
}
function FileCard({
file,
progress,
onRemove,
canUploadIndividual = false,
uploadIndividualFun,
}: FileCardProps) {
return (
<div className="relative flex items-center gap-2.5 rounded-md border border-transparent p-2 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex flex-1 gap-2.5">
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</p>
</div>
{progress ? <Progress value={progress} /> : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="size-7 dark:border-gray-600 dark:hover:bg-gray-700"
onClick={onRemove}
>
<X className="size-4" aria-hidden="true" />
<span className="sr-only">{t("PDF.removeFile")}</span>
</Button>
{canUploadIndividual && (
<Button type="button" variant="outline" className="dark:border-gray-600 dark:hover:bg-gray-700" onClick={uploadIndividualFun}>
upload
</Button>
)}
</div>
</div>
);
}
function isFileWithPreview(file: File): file is File & { preview: string } {
return "preview" in file && typeof file.preview === "string";
}
interface FilePreviewProps {
file: File & { preview: string };
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith("image/")) {
return (
<img
className="h-auto max-w-full"
src={file.preview}
alt={file.name}
width={48}
height={48}
loading="lazy"
/>
);
}
{
/* <Image
src={file.preview}
alt={file.name}
width={48}
height={48}
loading="lazy"
className="aspect-square shrink-0 rounded-md object-cover"
/> */
}
return (
<FileText className="size-10 text-muted-foreground" aria-hidden="true" />
);
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
function useCallbackRef<T extends (...args: never[]) => unknown>(
callback: T | undefined
): T {
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
});
return React.useMemo(
() => ((...args) => callbackRef.current?.(...args)) as T,
[]
);
}
export { useCallbackRef };

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { useCallbackRef } from "./use-callback-ref";
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
*/
type UseControllableStateParams<T> = {
prop?: T | undefined;
defaultProp?: T | undefined;
onChange?: (state: T) => void;
};
type SetStateFn<T> = (prevState?: T) => T;
function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
}: UseControllableStateParams<T>) {
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
defaultProp,
onChange,
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
const handleChange = useCallbackRef(onChange);
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> =
React.useCallback(
(nextValue) => {
if (isControlled) {
const setter = nextValue as SetStateFn<T>;
const value =
typeof nextValue === "function" ? setter(prop) : nextValue;
if (value !== prop) handleChange(value as T);
} else {
setUncontrolledProp(nextValue);
}
},
[isControlled, prop, setUncontrolledProp, handleChange]
);
return [value, setValue] as const;
}
function useUncontrolledState<T>({
defaultProp,
onChange,
}: Omit<UseControllableStateParams<T>, "prop">) {
const uncontrolledState = React.useState<T | undefined>(defaultProp);
const [value] = uncontrolledState;
const prevValueRef = React.useRef(value);
const handleChange = useCallbackRef(onChange);
React.useEffect(() => {
if (prevValueRef.current !== value) {
handleChange(value as T);
prevValueRef.current = value;
}
}, [value, prevValueRef, handleChange]);
return uncontrolledState;
}
export { useControllableState };

View File

@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
// Define the structure of an uploaded file
export interface UploadedFile {
name: string;
size: number;
type: string;
buffer: ArrayBuffer;
preview: string;
}
export interface UploadProgress {
[key: string]: number; // ✅ Use string index for compatibility
}
export function useUploadFile() {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [progresses, setProgresses] = useState<UploadProgress>({});
const [isUploading, setIsUploading] = useState<boolean>(false);
// Accepts File[] instead of FileList
const onUpload = async (files: File[]) => {
setIsUploading(true);
const filePromises: Promise<UploadedFile>[] = files.map((file, index) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const previewReader = new FileReader();
reader.onload = (event) => {
if (event.target?.result instanceof ArrayBuffer) {
previewReader.readAsDataURL(file); // Convert for preview
previewReader.onload = () => {
resolve({
name: file.name,
size: file.size,
type: file.type,
buffer: event.target!.result as ArrayBuffer,
preview: previewReader.result as string, // Store preview
});
};
} else {
reject(new Error("Failed to read file as ArrayBuffer."));
}
};
reader.onerror = () => reject(new Error("File reading error"));
reader.readAsArrayBuffer(file);
// Convert index to string for progresses
const fileIndex = index.toString();
setProgresses((prev) => ({ ...prev, [fileIndex]: 0 }));
setTimeout(() => {
setProgresses((prev) => ({ ...prev, [fileIndex]: 100 }));
}, 1000);
});
});
try {
const fileBuffers = await Promise.all(filePromises);
setUploadedFiles((prev) => [...prev, ...fileBuffers]);
} catch (error) {
} finally {
setIsUploading(false);
}
};
return {
onUpload,
progresses,
uploadedFiles,
isUploading,
};
}

View File

@@ -0,0 +1,75 @@
"use client";
import { FileUploader } from "./fileUpload";
import { useState, useEffect, useRef } from "react"; // Added useRef import
import { toast } from "sonner";
export interface FileInfo {
fileName: string;
contentType: string;
originalname: string;
}
export interface Name {
am: string;
en: string;
}
export interface UploadRequestBody {
fileInfo: FileInfo;
name: Name;
recordContentId: string;
}
export interface UploadResponse {
uploadUrl: string;
attachmentId: string;
}
export function ReusableFileUploader({
setFiles,
files,
canUploadIndividual = false,
isLoading,
uploadIndividualFun,
}: {
setFiles?: (files: File[]) => void;
files: File[];
isLoading: boolean;
canUploadIndividual?: boolean;
uploadIndividualFun?: () => void;
}) {
const [error, setError] = useState<string | null>(null);
const lastFiles = useRef<File[]>([]); // Now properly defined
useEffect(() => {
lastFiles.current = files;
}, [files]);
const handleFileChange = (newFiles: File[]) => {
if (setFiles) {
// Only update if files actually changed
if (
newFiles.length !== lastFiles.current.length ||
newFiles.some((f, i) => f !== lastFiles.current[i])
) {
setFiles(newFiles);
setError(null);
}
}
};
return (
<div className="flex flex-col gap-6">
<FileUploader
maxFileCount={30}
maxSize={200 * 1024 * 1024}
value={files}
onValueChange={handleFileChange}
disabled={isLoading}
canUploadIndividual={canUploadIndividual}
uploadIndividualFun={uploadIndividualFun}
/>
{isLoading && <p className="text-muted-foreground">Uploading...</p>}
{error && <p className="text-red-500 dark:text-red-400">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { ScrollArea, ScrollBar } from "@/shared/common/ui/scroll-area";
import { EmptyCard } from "./empty-card";
import { UploadedFile } from "./hooks/use-file-upload";
interface UploadedFilesCardProps {
uploadedFiles: UploadedFile[];
}
export function UploadedFilesCard({ uploadedFiles }: UploadedFilesCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Uploaded files</CardTitle>
<CardDescription>View the uploaded files here</CardDescription>
</CardHeader>
<CardContent>
{uploadedFiles.length > 0 ? (
<ScrollArea className="pb-4">
<div className="flex w-max space-x-2.5">
{uploadedFiles.map((file, index) => (
<div key={index} className="relative aspect-video w-64">
<img
src={file.preview}
alt={file.name}
className="rounded-md object-cover w-full h-full"
/>
{/* <Image
src={file.preview}
alt={file.name}
fill
sizes="(min-width: 640px) 640px, 100vw"
loading="lazy"
className="rounded-md object-cover"
/> */}
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
) : (
<EmptyCard
title="No files uploaded"
description="Upload some files to see them here"
className="w-full"
/>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(
bytes: number,
opts: {
decimals?: number;
sizeType?: "accurate" | "normal";
} = {}
) {
const { decimals = 0, sizeType = "normal" } = opts;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
if (bytes === 0) return "0 Byte";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
sizeType === "accurate" ? accurateSizes[i] ?? "Bytes" : sizes[i] ?? "Bytes"
}`;
}
/**
* Stole this from the @radix-ui/primitive
* @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
*/
export function composeEventHandlers<E>(
originalEventHandler?: (event: E) => void,
ourEventHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {}
) {
return function handleEvent(event: E) {
originalEventHandler?.(event);
if (
checkForDefaultPrevented === false ||
!(event as unknown as Event).defaultPrevented
) {
return ourEventHandler?.(event);
}
};
}

View File

@@ -0,0 +1,166 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/shared/lib/utils";
import { Label } from "@/shared/common/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive-foreground", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive-foreground text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import HabeshaCalendarUtils, {
HabeshaDate,
} from "@/shared/utils/habeshaCalendar";
export interface HabeshaDatePickerProps {
value?: Date;
onChange?: (date: Date) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
language?: "am" | "en";
}
export const HabeshaDatePicker: React.FC<HabeshaDatePickerProps> = ({
value,
onChange,
placeholder = "Select date",
disabled = false,
className,
language = "am",
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedYear, setSelectedYear] = useState<string>("");
const [selectedMonth, setSelectedMonth] = useState<string>("");
const [selectedDay, setSelectedDay] = useState<string>("");
const [displayText, setDisplayText] = useState<string>("");
// Initialize with current date or provided value
useEffect(() => {
if (value && value instanceof Date && !isNaN(value.getTime())) {
const habeshaDate = HabeshaCalendarUtils.gregorianToHabesha(value);
setSelectedYear(habeshaDate.year.toString());
setSelectedMonth(habeshaDate.month.toString());
setSelectedDay(habeshaDate.day.toString());
} else {
const current = HabeshaCalendarUtils.getCurrentHabeshaDate();
setSelectedYear(current.year.toString());
setSelectedMonth(current.month.toString());
setSelectedDay("");
}
}, [value]);
// Update display text when language or value changes
useEffect(() => {
if (value && value instanceof Date && !isNaN(value.getTime())) {
setDisplayText(HabeshaCalendarUtils.formatForDisplay(value, language));
} else {
setDisplayText("");
}
}, [value, language]);
const yearOptions = HabeshaCalendarUtils.generateHabeshaYearOptions(10, 5);
const monthOptions = React.useMemo(
() => HabeshaCalendarUtils.getMonthOptions(language),
[language]
);
// Generate day options based on selected month and year
const getDayOptions = () => {
if (!selectedYear || !selectedMonth) return [];
const year = parseInt(selectedYear);
const month = parseInt(selectedMonth);
const daysInMonth = HabeshaCalendarUtils.getDaysInHabeshaMonth(year, month);
return Array.from({ length: daysInMonth }, (_, i) => ({
label: (i + 1).toString(),
value: (i + 1).toString(),
}));
};
const handleDateSelect = () => {
if (selectedYear && selectedMonth && selectedDay) {
const year = parseInt(selectedYear);
const month = parseInt(selectedMonth);
const day = parseInt(selectedDay);
try {
const gregorianDate = HabeshaCalendarUtils.habeshaToGregorian(
year,
month,
day
);
onChange?.(gregorianDate);
setIsOpen(false);
} catch (error) {
console.error("Invalid Habesha date:", error);
}
}
};
const handleClear = () => {
setSelectedYear("");
setSelectedMonth("");
setSelectedDay("");
onChange?.(new Date()); // Reset to current date
};
const displayValue = displayText;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
className
)}
disabled={disabled}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{displayValue || placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">
{language === "am" ? "ቀን ይምረጡ" : "Select Date"}
</h4>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="text-xs"
>
{language === "am" ? "አጽዳ" : "Clear"}
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
{/* Year Selection */}
<div className="space-y-2">
<Label className="text-xs">
{language === "am" ? "ዓመት" : "Year"}
</Label>
<Select value={selectedYear} onValueChange={setSelectedYear}>
<SelectTrigger className="h-8">
<SelectValue
placeholder={language === "am" ? "ዓመት" : "Year"}
/>
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year.value} value={year.value}>
{year.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Month Selection */}
<div className="space-y-2">
<Label className="text-xs">
{language === "am" ? "ወር" : "Month"}
</Label>
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger className="h-8">
<SelectValue
placeholder={language === "am" ? "ወር" : "Month"}
/>
</SelectTrigger>
<SelectContent>
{monthOptions.map((month) => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Day Selection */}
<div className="space-y-2">
<Label className="text-xs">
{language === "am" ? "ቀን" : "Day"}
</Label>
<Select
value={selectedDay}
onValueChange={setSelectedDay}
disabled={!selectedYear || !selectedMonth}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={language === "am" ? "ቀን" : "Day"} />
</SelectTrigger>
<SelectContent>
{getDayOptions().map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
>
{language === "am" ? "ሰርዝ" : "Cancel"}
</Button>
<Button
size="sm"
onClick={handleDateSelect}
disabled={!selectedYear || !selectedMonth || !selectedDay}
>
{language === "am" ? "ምረጥ" : "Select"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default HabeshaDatePicker;

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/shared/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input dark:text-white dark:placeholder:text-muted-foreground/70",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/shared/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,427 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { CheckIcon, ChevronDown, XIcon, WandSparkles } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { Separator } from "@/shared/common/ui/separator";
import { Button } from "@/shared/common/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/shared/common/ui/command";
import { t } from "i18next";
const normalizeSearchText = (value: string) =>
value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/
const multiSelectVariants = cva(
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
{
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
dropDown:
"w-full border border-foreground/10 text-foreground bg-card hover:bg-card/80 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition duration-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
/**
* Props for MultiSelect component
*/
interface MultiSelectProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
*/
options: {
/** The text to display for the option. */
label: string;
/** The unique value associated with the option. */
value: string;
/** Optional icon component to display alongside the option. */
icon?: React.ComponentType<{ className?: string }>;
}[];
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
onValueChange: (value: string[]) => void;
/** The default selected values when the component mounts. */
defaultValue?: string[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
* Optional, defaults to 0 (no animation).
*/
animation?: number;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
/**
* Current selected values
*/
value?: string[];
}
export const MultiSelect = React.forwardRef<
HTMLButtonElement,
MultiSelectProps
>(
(
{
options = [],
onValueChange,
variant,
defaultValue = [],
placeholder = "Select options",
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
value,
...props
},
ref,
) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>(
value || defaultValue,
);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
const [isExtraOpen, setIsExtraOpen] = React.useState(false);
React.useEffect(() => {
if (value !== undefined) {
setSelectedValues(value);
}
}, [value]);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
// Function to remove a specific item without opening the dropdown
const removeItem = (valueToRemove: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent dropdown from opening
const newSelectedValues = selectedValues.filter(
(value) => value !== valueToRemove,
);
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const handleClear = (event: React.MouseEvent) => {
event.stopPropagation(); // Prevent dropdown from opening
setSelectedValues([]);
onValueChange([]);
};
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear(new MouseEvent("click") as unknown as React.MouseEvent);
} else {
const allValues = options.map((option) => option.value);
setSelectedValues(allValues);
onValueChange(allValues);
}
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
className={cn(
"flex w-full p-2 rounded-md border min-h-10 h-auto items-center justify-between bg-background hover:bg-background/90 [&_svg]:pointer-events-auto",
className,
)}>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap gap-1 items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<div
key={value}
className="flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary border border-primary/20 dark:bg-primary/20 dark:text-primary-foreground dark:border-primary/40 rounded-md">
{IconComponent && (
<IconComponent className="h-3 w-3 mr-1" />
)}
<span className="max-w-[150px] truncate text-sm">
{option?.label}
</span>
<XIcon
className="h-3.5 w-3.5 cursor-pointer text-primary dark:text-primary-foreground hover:text-red-600 ml-1"
onClick={(e) => removeItem(value, e)}
/>
</div>
);
})}
{selectedValues.length > maxCount && (
<Popover
open={isExtraOpen}
onOpenChange={setIsExtraOpen}
modal={true}>
<PopoverTrigger asChild>
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setIsExtraOpen((prev) => !prev);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setIsExtraOpen((prev) => !prev);
}
}}
className="flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 border border-gray-200 rounded-md cursor-pointer hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600"
title={t("msg.selectAll") /* tooltip fallback */}>
<span className="text-sm">{`+ ${
selectedValues.length - maxCount
} more`}</span>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 p-0"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{t("ApprovalPage.Selected", "Selected")}
</span>
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary dark:bg-primary/30 dark:text-primary-foreground">
{selectedValues.length}
</span>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{selectedValues.slice(maxCount).map((val) => {
const option = options.find((o) => o.value === val);
if (!option) return null;
const IconComponent = option.icon;
return (
<div
key={val}
className="flex items-center justify-between gap-2 px-3 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-800">
<span className="flex items-center gap-2 min-w-0">
{IconComponent && (
<IconComponent className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="text-sm truncate">
{option.label}
</span>
</span>
<XIcon
className="h-3.5 w-3.5 cursor-pointer text-slate-500 dark:text-slate-400 hover:text-red-600 shrink-0"
onClick={(e) => removeItem(val, e)}
/>
</div>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="flex items-center justify-between">
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="text-sm text-muted-foreground mx-3">
{placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0 max-w-[400px] dark:bg-gray-800 dark:border-gray-700"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
onWheel={(e) => {
// Prevent the wheel event from bubbling up to parent elements
e.stopPropagation();
}}>
<Command
filter={(value, search, keywords = []) => {
const normalizedSearch = normalizeSearchText(search);
if (!normalizedSearch) return 1;
const haystacks = [value, ...keywords]
.map((item) => normalizeSearchText(item))
.filter(Boolean);
return haystacks.some((item) => item.includes(normalizedSearch))
? 1
: 0;
}}>
<CommandInput
placeholder={t("ApprovalPage.Search")}
onKeyDown={handleInputKeyDown}
/>
<CommandList className="max-h-[200px] overflow-y-auto custom-scrollbar scroll-container">
<CommandEmpty>{t("common.noResult")}</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
onSelect={toggleAll}
className="cursor-pointer">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}>
<CheckIcon className="h-4 w-4" />
</div>
<span>({t("msg.selectAll")})</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
value={option.label}
keywords={[option.value, option.label]}
onSelect={() => toggleOption(option.value)}
className="cursor-pointer">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onClick={handleClear}
className="flex-1 justify-center cursor-pointer">
{t("userIncoming.Clear")}
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full">
{t("complaint.close")}
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
},
);
MultiSelect.displayName = "MultiSelect";

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { Button, buttonVariants } from "@/shared/common/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/shared/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/shared/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/shared/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/shared/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,183 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
onScroll,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
onScroll={onScroll}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/shared/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,137 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/shared/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,727 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/record-management/hooks/use-mobile";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Separator } from "@/shared/common/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/common/ui/sheet";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "80vw"; // Changed from fixed size to viewport-relative
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:top-4 [&>button]:right-4 max-w-full"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile } = useSidebar();
const Comp = asChild ? Slot : "button";
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(
sidebarMenuButtonVariants({ variant, size }),
isActive && "text-accent-foreground [&_svg]:text-accent-foreground",
isMobile && "min-h-[44px]",
className
)}
{...props}
/>
);
// On mobile, we don't show tooltips
if (isMobile || !tooltip) {
return button;
}
// Desktop experience with tooltips
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
{...(typeof tooltip === "object" ? tooltip : {})}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,154 @@
import * as React from "react";
import { Button } from "@/shared/common/ui/button";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/shared/common/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
CommandGroup,
} from "@/shared/common/ui/command";
import { CheckIcon, ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/utils";
const normalizeSearchText = (value: string) =>
value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
interface SingleSelectProps {
options: { label: string; value: string }[];
value?: string;
placeholder?: string;
onValueChange: (value: string) => void;
className?: string;
}
export const SingleSelect = React.forwardRef<
HTMLButtonElement,
SingleSelectProps
>(
(
{
options = [],
value,
placeholder = "Select an option",
onValueChange,
className,
...props
},
ref,
) => {
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const normalizedQuery = normalizeSearchText(searchQuery);
const filteredOptions = options.filter((opt) =>
normalizeSearchText(opt.label).includes(normalizedQuery),
);
const selectedOption = options.find((o) => o.value === value);
const handleSelect = (val: string) => {
onValueChange(val);
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
className={cn(
"flex h-10 w-full min-w-0 max-w-full shrink items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white text-gray-900",
"px-3 py-2 text-sm font-normal shadow-sm transition-all duration-150",
"hover:bg-gray-50 hover:border-gray-400 focus:ring-2 focus:ring-primary-200 focus:border-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200",
"dark:hover:bg-gray-600 dark:hover:border-gray-500",
isOpen &&
"border-primary-500 ring-2 ring-primary-100 dark:ring-primary-900/40",
className,
)}
>
<span
className={cn(
"block min-w-0 flex-1 truncate text-left",
selectedOption
? "font-medium text-gray-900 dark:text-gray-100"
: "font-normal text-gray-500 dark:text-gray-400",
)}
title={selectedOption ? selectedOption.label : undefined}
>
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDown
className={cn(
"ml-2 h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200",
"dark:text-gray-400",
isOpen && "rotate-180",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] max-w-full p-0 border border-gray-200 rounded-lg shadow-lg bg-white dark:border-gray-600 dark:bg-gray-800"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
value={searchQuery}
onValueChange={setSearchQuery}
className="text-sm dark:text-gray-200 dark:placeholder:text-gray-500"
/>
<CommandList
className="max-h-60 overflow-y-auto"
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty className="py-3 text-gray-500 text-sm text-center dark:text-gray-400">
No results found.
</CommandEmpty>
<CommandGroup>
{filteredOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => handleSelect(opt.value)}
className={cn(
"cursor-pointer flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors duration-100",
"hover:bg-primary-50 hover:text-primary-700",
"focus:bg-primary-50",
"dark:hover:bg-primary-700 dark:focus:bg-primary-700 dark:text-gray-200",
// Remove cmdk's default active styling
"data-[selected=true]:text-inherit",
"data-[selected=true]:bg-transparent",
value === opt.value &&
"bg-primary-100 text-primary-700 font-medium dark:bg-primary-900/50 dark:text-primary-300",
)}
>
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
{value === opt.value && (
<CheckIcon className="h-4 w-4 shrink-0 text-primary-600" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
},
);
SingleSelect.displayName = "SingleSelect";

View File

@@ -0,0 +1,13 @@
import { cn } from "@/shared/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,27 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,49 @@
import React from "react";
import { cn } from "@/shared/lib/utils";
import { VariantProps, cva } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import Loader from "@/record-management/common/loader";
const spinnerVariants = cva("flex-col items-center justify-center", {
variants: {
show: {
true: "flex",
false: "hidden",
},
},
defaultVariants: {
show: true,
},
});
const loaderVariants = cva("animate-spin text-primary", {
variants: {
size: {
small: "size-6",
medium: "size-8",
large: "size-12",
},
},
defaultVariants: {
size: "medium",
},
});
interface SpinnerContentProps
extends VariantProps<typeof spinnerVariants>,
VariantProps<typeof loaderVariants> {
className?: string;
children?: React.ReactNode;
}
export function Spinner({
show,
children,
}: SpinnerContentProps) {
return (
<span className={spinnerVariants({ show })}>
<Loader />
{children}
</span>
);
}

View File

@@ -0,0 +1,152 @@
import React, { useState, useCallback } from "react";
import { Star } from "lucide-react";
import { cn } from "@/shared/lib/utils";
export interface StarRatingProps {
value?: number;
onChange?: (rating: number) => void;
maxRating?: number;
size?: "sm" | "md" | "lg";
disabled?: boolean;
showLabels?: boolean;
className?: string;
labels?: string[];
language?: "am" | "en";
}
const defaultLabelsEn = ["Very Poor", "Poor", "Normal", "Good", "Excellent"];
const defaultLabelsAm = ["በጣም መጥፎ", "መጥፎ", "መካከለኛ", "ጥሩ", "በጣም ጥሩ"];
export const StarRating: React.FC<StarRatingProps> = ({
value = 0,
onChange,
maxRating = 5,
size = "md",
disabled = false,
showLabels = true,
className,
labels,
language = "en",
}) => {
// Use provided labels or default based on language
const ratingLabels =
labels || (language === "am" ? defaultLabelsAm : defaultLabelsEn);
const [hoverRating, setHoverRating] = useState(0);
const [isHovering, setIsHovering] = useState(false);
const sizeClasses = {
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
};
const handleStarClick = useCallback(
(rating: number) => {
if (!disabled && onChange) {
// Use requestAnimationFrame to prevent vibration
requestAnimationFrame(() => {
onChange(rating);
});
}
},
[disabled, onChange]
);
const handleStarHover = useCallback(
(rating: number) => {
if (!disabled) {
if (!isHovering || rating !== hoverRating) {
setHoverRating(rating);
setIsHovering(true);
}
}
},
[disabled, isHovering, hoverRating]
);
const handleMouseLeave = useCallback(() => {
if (isHovering) {
setIsHovering(false);
setHoverRating(0);
}
}, [isHovering]);
const displayRating = isHovering ? hoverRating : value;
const currentLabel = ratingLabels[displayRating - 1] || "";
return (
<div
className={cn("flex flex-col items-center gap-4", className)}
style={{
backfaceVisibility: "hidden",
transform: "translateZ(0)",
willChange: "auto",
}}
>
<div
className="flex items-center justify-center gap-2"
onMouseLeave={handleMouseLeave}
style={{
backfaceVisibility: "hidden",
transform: "translateZ(0)",
}}
>
{Array.from({ length: maxRating }, (_, index) => {
const starRating = index + 1;
const isFilled = starRating <= displayRating;
return (
<button
key={index}
type="button"
className={cn(
"relative flex items-center justify-center transition-colors duration-150 ease-out focus:outline-none focus:ring-2 focus:ring-primary/20 rounded-lg p-2",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-yellow-50/30"
)}
onClick={() => handleStarClick(starRating)}
onMouseEnter={() => handleStarHover(starRating)}
disabled={disabled}
style={{
touchAction: "manipulation",
backfaceVisibility: "hidden",
transform: "translate3d(0, 0, 0)",
WebkitFontSmoothing: "antialiased",
minWidth: "3rem",
minHeight: "3rem",
}}
>
<Star
className={cn(
sizeClasses[size],
"transition-colors duration-150 ease-out",
isFilled
? "fill-yellow-400 text-yellow-400"
: "fill-transparent text-gray-300"
)}
style={{
backfaceVisibility: "hidden",
transform: "translate3d(0, 0, 0)",
WebkitFontSmoothing: "antialiased",
}}
/>
</button>
);
})}
</div>
{showLabels && (
<div className="text-center min-h-[2.5rem] flex items-center justify-center">
{displayRating > 0 && (
<div className="text-lg font-semibold text-gray-800 bg-yellow-50 px-4 py-2 rounded-full border border-yellow-200 transition-opacity duration-150">
{currentLabel}
</div>
)}
</div>
)}
</div>
);
};
export default StarRating;

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/super-admin/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,232 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { DataTablePaginationFinal } from "./DataTablePaginationFinal";
import { DataTableToolbar } from "./DataTableToolbar";
import { useTranslation } from "react-i18next";
import { usePersistedColumnVisibility } from "@/shared/hooks/usePersistedColumnVisibility";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
tableName: string;
initialColumnVisibility?: VisibilityState;
extraToolbar?: React.ReactNode;
toolBarPosition?: "right" | "left" | "center";
isLoading?: boolean;
itemCount: number;
pageIndex: number; // <-- new
pageSize?: number; // <-- new
onPageChange: (pageIndex: number) => void; // <-- required
onPageSizeChange?: (size: number) => void; // <-- new
nextFunction: () => void;
prevFunction: () => void;
refresh?: () => void;
onGlobalFilterChange?: (value: string) => void;
/** Hide the toolbar's "Filter <tableName>..." text input. Use when the page
* already provides its own search/filter UI above the table. */
hideToolbarFilter?: boolean;
/** Disable client-side filtering when using server-side filtering */
disableClientFiltering?: boolean;
}
interface incomingHistory {
status: string;
record: { recordTypeKey: string };
id: string;
isUrgent?: boolean;
}
export function AdvancedTable<TData, TValue>({
columns,
data,
tableName,
initialColumnVisibility,
extraToolbar,
toolBarPosition = "right",
isLoading = false,
itemCount,
onPageChange,
onPageSizeChange,
nextFunction,
prevFunction,
pageIndex,
pageSize = 10,
refresh,
onGlobalFilterChange,
hideToolbarFilter = false,
disableClientFiltering = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] = usePersistedColumnVisibility(
tableName,
{
letterName: false,
fileName: false,
...(initialColumnVisibility ?? {}),
},
);
const [rowSelection, setRowSelection] = React.useState({});
const { t } = useTranslation();
const table = useReactTable({
data,
columns,
pageCount: pageSize > 0 ? Math.ceil(itemCount / pageSize) : 1,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination: {
pageIndex, // now controlled from props
pageSize,
},
},
manualPagination: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
...(disableClientFiltering
? {}
: { getFilteredRowModel: getFilteredRowModel() }),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
<div className="space-y-4">
<DataTableToolbar
table={table}
tableName={tableName}
extraToolbar={extraToolbar}
toolBarPosition={toolBarPosition}
refreshTable={refresh}
onGlobalFilterChange={onGlobalFilterChange}
hideToolbarFilter={hideToolbarFilter}
disableClientFiltering={disableClientFiltering}
/>
<div className="rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 overflow-x-auto font-urbanist custom-scrollbar">
<Table className="w-full text-base text-gray-700 dark:text-gray-300 font-urbanist">
<TableHeader className="bg-gray-50 dark:bg-gray-800">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="dark:border-gray-700">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="text-base text-gray-500 dark:text-gray-400 dark:bg-gray-800"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-6"
>
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={`
transition-colors duration-150
${
((row.original as incomingHistory).isUrgent === true &&
tableName === "Pending Records") ||
((row.original as any).action === "inprogress" &&
tableName === "My Approval Records")
? "bg-yellow-200 dark:bg-yellow-900/30 hover:bg-yellow-300 dark:hover:bg-yellow-900/50"
: "hover:bg-gray-100 dark:hover:bg-gray-800"
}
${
(((row.original as any).signStatus === "draft" &&
tableName === "Collaborations") ||
tableName === "Pending Approval Tasks") &&
"bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 font-semibold border-l-4 border-l-primary-500 dark:border-l-primary-400"
}
`}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
<div className="max-h-[110px] max-w-[460px] overflow-y-auto overflow-x-auto scrollbar-hidden hover:cursor-pointer custom-scrollbar">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-6"
>
{t("common.noResult")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{itemCount > pageSize && (
<DataTablePaginationFinal
pageIndex={pageIndex}
pageSize={pageSize}
itemCount={itemCount}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
nextFunction={nextFunction}
prevFunction={prevFunction}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,149 @@
import * as React from "react";
import { Column } from "@tanstack/react-table";
import { cn } from "@/super-admin/lib/utils";
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/shared/common/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { Separator } from "@/shared/common/ui/separator";
import { CheckIcon, PlusCircle } from "lucide-react";
export type FilterOption = {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
};
interface DataTableFacetedFilter<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: FilterOption[];
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(
filterValues.length ? filterValues : undefined
);
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,178 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { cn } from "@/shared/lib/utils";
import { useMemo } from "react";
interface DataTablePaginationProps {
pageIndex: number; // current page index (0-based)
pageSize?: number; // items per page
itemCount: number; // total number of items
onPageChange: (page: number) => void;
nextFunction: () => void;
prevFunction: () => void;
/** Maximum number of numeric page buttons rendered before truncating with ellipsis. */
siblingCount?: number;
}
const ELLIPSIS = "…" as const;
type PageItem = number | typeof ELLIPSIS;
function buildPageItems(
currentPage: number,
totalPages: number,
siblingCount: number,
): PageItem[] {
// Always show first, last, current, and `siblingCount` pages around current.
// Insert ellipsis where there are gaps.
const totalNumbers = siblingCount * 2 + 5; // first + last + current + 2*siblings + 2 ellipses
if (totalPages <= totalNumbers) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const leftSibling = Math.max(currentPage - siblingCount, 2);
const rightSibling = Math.min(currentPage + siblingCount, totalPages - 1);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < totalPages - 1;
const items: PageItem[] = [1];
if (showLeftEllipsis) items.push(ELLIPSIS);
for (let i = leftSibling; i <= rightSibling; i++) items.push(i);
if (showRightEllipsis) items.push(ELLIPSIS);
items.push(totalPages);
return items;
}
export function DataTablePagination({
pageIndex,
pageSize = 10,
itemCount,
onPageChange,
nextFunction,
prevFunction,
siblingCount = 1,
}: DataTablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(itemCount / pageSize));
const currentPage = pageIndex + 1; // 1-based for display
const canGoNext = pageIndex < totalPages - 1;
const canGoPrev = pageIndex > 0;
const pageItems = useMemo(
() => buildPageItems(currentPage, totalPages, siblingCount),
[currentPage, totalPages, siblingCount],
);
// Edge case: hide entirely when there's nothing to paginate.
if (totalPages <= 1 || itemCount === 0) return null;
const handleNext = () => {
if (canGoNext) {
onPageChange(pageIndex + 1);
nextFunction();
}
};
const handlePrev = () => {
if (canGoPrev) {
onPageChange(pageIndex - 1);
prevFunction();
}
};
const goToPage = (page: number) => {
const clamped = Math.min(Math.max(page, 1), totalPages);
if (clamped - 1 === pageIndex) return;
onPageChange(clamped - 1);
};
const rangeStart = pageIndex * pageSize + 1;
const rangeEnd = Math.min(rangeStart + pageSize - 1, itemCount);
return (
<nav
role="navigation"
aria-label="Pagination"
className="flex flex-col gap-3 px-2 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<p className="text-muted-foreground dark:text-gray-400">
Showing{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{rangeStart}
</span>
{""}
<span className="font-medium text-gray-700 dark:text-gray-200">
{rangeEnd}
</span>{" "}
of{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{itemCount}
</span>
</p>
<div className="flex flex-wrap items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
onClick={handlePrev}
disabled={!canGoPrev}
aria-label="Previous page"
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Previous</span>
</Button>
<ul className="flex items-center gap-1">
{pageItems.map((item, idx) => {
if (item === ELLIPSIS) {
return (
<li
key={`ellipsis-${idx}`}
aria-hidden="true"
className="flex h-8 w-8 items-center justify-center text-gray-400 dark:text-gray-500"
>
{ELLIPSIS}
</li>
);
}
const isActive = item === currentPage;
return (
<li key={item}>
<Button
type="button"
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={() => goToPage(item)}
aria-current={isActive ? "page" : undefined}
aria-label={`Go to page ${item}`}
className={cn(
"h-8 min-w-8 px-2 text-sm transition-colors",
isActive
? "bg-primary-100 text-primary-800 font-bold hover:bg-primary-200 dark:bg-primary-900 dark:text-primary-300 dark:hover:bg-primary-800"
: "font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700",
)}
>
{item}
</Button>
</li>
);
})}
</ul>
<Button
variant="outline"
size="sm"
onClick={handleNext}
disabled={!canGoNext}
aria-label="Next page"
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,260 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { cn } from "@/shared/lib/utils";
import { useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Table } from "@tanstack/react-table";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface DataTablePaginationProps<TData = any> {
table?: Table<TData>;
pageIndex?: number;
pageSize?: number;
itemCount?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (size: number) => void;
pageCount?: number;
nextFunction?: () => void;
prevFunction?: () => void;
/** Maximum number of numeric page buttons rendered before truncating with ellipsis. */
siblingCount?: number;
}
const ELLIPSIS = "…" as const;
type PageItem = number | typeof ELLIPSIS;
function buildPageItems(
currentPage: number,
totalPages: number,
siblingCount: number,
): PageItem[] {
const totalNumbers = siblingCount * 2 + 5;
if (totalPages <= totalNumbers) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const leftSibling = Math.max(currentPage - siblingCount, 2);
const rightSibling = Math.min(currentPage + siblingCount, totalPages - 1);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < totalPages - 1;
const items: PageItem[] = [1];
if (showLeftEllipsis) items.push(ELLIPSIS);
for (let i = leftSibling; i <= rightSibling; i++) items.push(i);
if (showRightEllipsis) items.push(ELLIPSIS);
items.push(totalPages);
return items;
}
export function DataTablePaginationFinal<TData>({
table,
pageIndex: pageIndexProp,
pageSize: pageSizeProp,
itemCount: itemCountProp,
onPageChange,
onPageSizeChange,
nextFunction,
prevFunction,
pageCount: pageCountProp,
siblingCount = 1,
}: DataTablePaginationProps<TData>) {
// Derive page size
const pageSize = table
? (table.getState().pagination?.pageSize ?? 10)
: (pageSizeProp ?? 10);
// Derive page index (0-based)
const pageIndex = table
? (table.getState().pagination?.pageIndex ?? 0)
: (pageIndexProp ?? 0);
// Derive total pages
const totalPages = table
? table.getPageCount()
: (pageCountProp ?? Math.max(1, Math.ceil((itemCountProp ?? 0) / pageSize)));
const currentPage = pageIndex + 1;
const canGoNext = table ? table.getCanNextPage() : pageIndex < totalPages - 1;
const canGoPrev = table ? table.getCanPreviousPage() : pageIndex > 0;
const pageItems = useMemo(
() => buildPageItems(currentPage, totalPages, siblingCount),
[currentPage, totalPages, siblingCount],
);
// Safely determine if the table is completely empty
const isEmpty = table
? table.getCoreRowModel().rows.length === 0 && (!itemCountProp || itemCountProp === 0)
: (itemCountProp !== undefined && itemCountProp === 0);
if (isEmpty) return null;
// Hide pagination if there is at most 1 page and no way to change page size
const hasPageSizeSelector = !!table || !!onPageSizeChange;
if (totalPages <= 1 && !hasPageSizeSelector) return null;
const handleNext = () => {
if (canGoNext) {
if (table) {
table.nextPage();
} else {
onPageChange?.(pageIndex + 1);
}
nextFunction?.();
}
};
const handlePrev = () => {
if (canGoPrev) {
if (table) {
table.previousPage();
} else {
onPageChange?.(pageIndex - 1);
}
prevFunction?.();
}
};
const goToPage = (page: number) => {
const clamped = Math.min(Math.max(page, 1), totalPages);
if (clamped - 1 === pageIndex) return;
if (table) {
table.setPageIndex(clamped - 1);
} else {
onPageChange?.(clamped - 1);
}
};
const handlePageSizeChange = (size: number) => {
if (table) {
table.setPageSize(size);
} else {
onPageSizeChange?.(size);
}
};
const rangeStart = pageIndex * pageSize + 1;
const rangeEnd = Math.min(rangeStart + pageSize - 1, itemCountProp ?? 0);
return (
<nav
role="navigation"
aria-label="Pagination"
className="flex flex-col gap-3 px-2 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
{hasPageSizeSelector ? (
<div className="flex items-center space-x-2">
<p className="text-xs sm:text-sm font-medium whitespace-nowrap">
Rows per page
</p>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
handlePageSizeChange(Number(value));
}}
>
<SelectTrigger className="h-9 sm:h-8 w-[70px] min-w-[70px] text-sm">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<p className="text-xs sm:text-sm text-muted-foreground dark:text-gray-400">
Showing{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{rangeStart}
</span>
{""}
<span className="font-medium text-gray-700 dark:text-gray-200">
{rangeEnd}
</span>{" "}
of{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{itemCountProp}
</span>
</p>
)}
<div className="flex flex-wrap items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
onClick={handlePrev}
disabled={!canGoPrev}
aria-label="Previous page"
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Previous</span>
</Button>
<ul className="flex items-center gap-1">
{pageItems.map((item, idx) => {
if (item === ELLIPSIS) {
return (
<li
key={`ellipsis-${idx}`}
aria-hidden="true"
className="flex h-8 w-8 items-center justify-center text-gray-400 dark:text-gray-500"
>
{ELLIPSIS}
</li>
);
}
const isActive = item === currentPage;
return (
<li key={item}>
<Button
type="button"
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={() => goToPage(item)}
aria-current={isActive ? "page" : undefined}
aria-label={`Go to page ${item}`}
className={cn(
"h-8 min-w-8 px-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground font-bold hover:bg-primary/90 dark:bg-primary dark:text-primary-foreground"
: "font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700",
)}
>
{item}
</Button>
</li>
);
})}
</ul>
<Button
variant="outline"
size="sm"
onClick={handleNext}
disabled={!canGoNext}
aria-label="Next page"
className="h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { useTranslation } from "react-i18next";
import { RotateCcw, X } from "lucide-react";
import { ReactNode } from "react";
import { DataTableFacetedFilter } from "./DataTableFacetedFilter";
import { DataTableViewOptions } from "./DataTableViewOptions";
import { useState } from "react";
export interface FilterConfig {
columnKey: string;
title: string;
options: { label: string; value: string }[];
isVisible?: boolean;
}
export interface DataTableToolbarProps<TData> {
table: Table<TData>;
tableName: string;
toolBarPosition?: "right" | "left" | "center";
extraToolbar?: ReactNode;
filters?: FilterConfig[];
refreshTable?: () => void;
onGlobalFilterChange?: (value: string) => void;
/** Hide the built-in "Filter <tableName>..." text input. Pages that own
* their own search/filter UI (e.g. outgoing/incoming/pending/userRecords)
* pass true so the toolbar doesn't show a redundant filter input. */
hideToolbarFilter?: boolean;
/** Disable client-side filtering when using server-side filtering */
disableClientFiltering?: boolean;
}
export function DataTableToolbar<TData>({
table,
tableName,
toolBarPosition = "left",
extraToolbar,
filters = [],
refreshTable,
onGlobalFilterChange,
hideToolbarFilter = false,
disableClientFiltering = false,
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0;
return (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{/* Filters Section */}
<div className="flex flex-wrap items-center gap-2">
{!hideToolbarFilter && (
<Input
placeholder={`Filter ${tableName}...`}
onChange={(event) => {
const value = event.target.value;
if (!disableClientFiltering) {
table.setGlobalFilter(value);
}
onGlobalFilterChange?.(value);
}}
className="h-8 w-full sm:w-[150px] lg:w-[250px]"
/>
)}
{filters.map(
({ columnKey, title, options, isVisible = true }) =>
isVisible &&
table.getColumn(columnKey) && (
<DataTableFacetedFilter
key={columnKey}
column={table.getColumn(columnKey)}
title={title}
options={options}
/>
)
)}
{isFiltered && (
<Button
variant="ghost"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
{/* Extra Toolbar & View Options Section */}
<div
className={`flex flex-wrap items-center gap-2 ${
toolBarPosition === "center"
? "justify-center"
: toolBarPosition === "left"
? "justify-start"
: "justify-end"
}`}
>
{extraToolbar}
<DataTableViewOptions table={table} refreshTable={refreshTable} />
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More