fix
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 969 B |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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-<pluginName>="<option name>: '<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-<pluginName></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-<pluginName>="<option name>: '<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);
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||