@per-diem-calculator/vanilla 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +17 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/eslint.config.js +29 -0
- package/index.html +11 -0
- package/package.json +49 -0
- package/public/output.css +2503 -0
- package/src/css/_styles.css +8 -0
- package/src/css/colors.css +45 -0
- package/src/css/fonts.css +9 -0
- package/src/css/rows/_heights.css +6 -0
- package/src/css/rows/_index.css +15 -0
- package/src/css/rows/add.css +18 -0
- package/src/css/rows/animate-btns.css +18 -0
- package/src/css/rows/animate-row-close.css +18 -0
- package/src/css/rows/animate-row-open.css +14 -0
- package/src/css/rows/animate-row-other.css +5 -0
- package/src/css/rows/btn-add-row.css +41 -0
- package/src/css/rows/btn-delete.css +22 -0
- package/src/css/rows/btn-expenses-calculate.css +22 -0
- package/src/css/rows/btn-expenses-category.css +22 -0
- package/src/css/rows/delete.css +10 -0
- package/src/css/rows/details.css +22 -0
- package/src/css/rows/expense.css +18 -0
- package/src/css/rows/location.css +34 -0
- package/src/css/rows/summary.css +22 -0
- package/src/css/tom-select/defaults.css +530 -0
- package/src/css/tom-select/overrides.css +55 -0
- package/src/css/tw-shadow-props.css +50 -0
- package/src/index.ts +1 -0
- package/src/ts/components/Button/Button.ts +50 -0
- package/src/ts/components/Button/template.html +34 -0
- package/src/ts/components/ExpenseRow/ExpenseRow.ts +397 -0
- package/src/ts/components/ExpenseRow/template.html +260 -0
- package/src/ts/components/Label/Label.ts +45 -0
- package/src/ts/components/Label/template.html +1 -0
- package/src/ts/components/LocationCategory/LocationCategory.ts +226 -0
- package/src/ts/components/LocationCategory/template.html +520 -0
- package/src/ts/components/LocationDate/LocationDate.ts +366 -0
- package/src/ts/components/LocationDate/template.html +27 -0
- package/src/ts/components/LocationSelect/LocationSelect.ts +299 -0
- package/src/ts/components/LocationSelect/template.html +45 -0
- package/src/ts/components/index.ts +6 -0
- package/src/ts/controller.ts +193 -0
- package/src/ts/model.ts +163 -0
- package/src/ts/types/config.ts +22 -0
- package/src/ts/types/dates.ts +82 -0
- package/src/ts/types/expenses.ts +73 -0
- package/src/ts/types/locations.ts +25 -0
- package/src/ts/utils/config/configDefault.ts +13 -0
- package/src/ts/utils/config/index.ts +12 -0
- package/src/ts/utils/config/numbers.ts +24 -0
- package/src/ts/utils/config/sanitizeConfig.ts +39 -0
- package/src/ts/utils/dates/INPUT_DATE_MINMAX.ts +5 -0
- package/src/ts/utils/dates/YEAR_REGEX.ts +4 -0
- package/src/ts/utils/dates/getDateSlice.ts +54 -0
- package/src/ts/utils/dates/getValidAPIYear.ts +17 -0
- package/src/ts/utils/dates/index.ts +19 -0
- package/src/ts/utils/dates/isDateRaw.ts +90 -0
- package/src/ts/utils/dates/isShortMonth.ts +24 -0
- package/src/ts/utils/dates/isYYYY.ts +10 -0
- package/src/ts/utils/dates/offsetDateString.ts +17 -0
- package/src/ts/utils/expenses/INTL_MIE_RATES.ts +2125 -0
- package/src/ts/utils/expenses/createExpenseObjs.ts +35 -0
- package/src/ts/utils/expenses/getLodgingRateDomestic.ts +73 -0
- package/src/ts/utils/expenses/getLodgingRateIntl.ts +119 -0
- package/src/ts/utils/expenses/getMieRates.ts +84 -0
- package/src/ts/utils/expenses/index.ts +5 -0
- package/src/ts/utils/expenses/parseIntlLodgingRates.ts +124 -0
- package/src/ts/utils/expenses/returnValidStateExpense.ts +46 -0
- package/src/ts/utils/fetch/fetchJsonGSA.ts +29 -0
- package/src/ts/utils/fetch/fetchXmlDOD.ts +38 -0
- package/src/ts/utils/fetch/index.ts +3 -0
- package/src/ts/utils/fetch/memoize.ts +46 -0
- package/src/ts/utils/fetch/parseXml.ts +19 -0
- package/src/ts/utils/locations/getCitiesDomestic.ts +48 -0
- package/src/ts/utils/locations/getCitiesIntl.ts +63 -0
- package/src/ts/utils/locations/getCountriesDomestic.ts +237 -0
- package/src/ts/utils/locations/getCountriesIntl.ts +34 -0
- package/src/ts/utils/locations/index.ts +6 -0
- package/src/ts/utils/locations/keepUniqueLocations.ts +12 -0
- package/src/ts/utils/locations/locationKeys.ts +10 -0
- package/src/ts/utils/locations/returnValidStateLocation.ts +13 -0
- package/src/ts/utils/locations/sortLocations.ts +19 -0
- package/src/ts/utils/misc/USD.ts +4 -0
- package/src/ts/utils/misc/debounce.ts +22 -0
- package/src/ts/utils/misc/handlePointerDown.ts +3 -0
- package/src/ts/utils/misc/handlePointerUp.ts +22 -0
- package/src/ts/utils/misc/inPrimitiveType.ts +4 -0
- package/src/ts/utils/misc/index.ts +6 -0
- package/src/ts/utils/misc/wait.ts +4 -0
- package/src/ts/utils/styles/applyStyles.ts +19 -0
- package/src/ts/utils/styles/highlightInput.ts +15 -0
- package/src/ts/utils/styles/index.ts +3 -0
- package/src/ts/utils/styles/removeStyles.ts +14 -0
- package/src/ts/views/Expense/Expense.ts +465 -0
- package/src/ts/views/Expense/template.html +176 -0
- package/src/ts/views/Location/Location.ts +763 -0
- package/src/ts/views/Location/template-row.html +146 -0
- package/src/ts/views/Location/template.html +130 -0
- package/src/ts/views/index.ts +2 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +12 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<div
|
|
2
|
+
class="group flex items-center justify-between border-b border-b-neutral-100
|
|
3
|
+
px-3"
|
|
4
|
+
>
|
|
5
|
+
<pdc-label styled="true"></pdc-label>
|
|
6
|
+
<select hidden></select>
|
|
7
|
+
<button
|
|
8
|
+
tabindex="0"
|
|
9
|
+
type="button"
|
|
10
|
+
id=""
|
|
11
|
+
class="group-[.active]:focus-visible:border-primary-800 absolute
|
|
12
|
+
right-[24px] rounded-full border-3 border-transparent p-1
|
|
13
|
+
transition-colors focus:outline-none
|
|
14
|
+
group-[.active]:focus-visible:[&_svg]:-rotate-180"
|
|
15
|
+
>
|
|
16
|
+
<svg
|
|
17
|
+
class="group-[.active]:group-hover:stroke-primary-800
|
|
18
|
+
group-[.active]:group-focus-visible:stroke-primary-800 size-4
|
|
19
|
+
shrink-0 rotate-0 fill-none stroke-neutral-400
|
|
20
|
+
transition-[transform_stroke] duration-500
|
|
21
|
+
group-[.active]:group-hover:-rotate-180 sm:size-5"
|
|
22
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
>
|
|
25
|
+
<path
|
|
26
|
+
stroke-linecap="round"
|
|
27
|
+
stroke-linejoin="round"
|
|
28
|
+
stroke-width="2"
|
|
29
|
+
d="M19 9l-7 7-7-7"
|
|
30
|
+
/>
|
|
31
|
+
</svg>
|
|
32
|
+
</button>
|
|
33
|
+
<!-- Loading indicator-->
|
|
34
|
+
<div
|
|
35
|
+
id="loading-spinner"
|
|
36
|
+
class="absolute z-0 flex w-full items-center justify-center bg-inherit
|
|
37
|
+
opacity-0 transition-opacity duration-700 [.active]:z-50
|
|
38
|
+
[.active]:opacity-100"
|
|
39
|
+
>
|
|
40
|
+
<div
|
|
41
|
+
class="border-t-primary-600 border-primary-50 h-10 w-10 animate-spin
|
|
42
|
+
rounded-full border-4 border-t-4"
|
|
43
|
+
></div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { PdcExpenseRow } from './ExpenseRow/ExpenseRow';
|
|
2
|
+
export { PdcLocationCategory } from './LocationCategory/LocationCategory';
|
|
3
|
+
export { PdcLocationSelect } from './LocationSelect/LocationSelect';
|
|
4
|
+
export { PdcLocationDate } from './LocationDate/LocationDate';
|
|
5
|
+
export { PdcButton } from './Button/Button';
|
|
6
|
+
export { PdcLabel } from './Label/Label';
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
import type {
|
|
3
|
+
LocationKeys,
|
|
4
|
+
StateLocationItem,
|
|
5
|
+
AllViewLocationsValid,
|
|
6
|
+
} from './types/locations';
|
|
7
|
+
import { StateExpenseItemUpdate } from './types/expenses';
|
|
8
|
+
import type { Config } from './types/config';
|
|
9
|
+
|
|
10
|
+
// Utils
|
|
11
|
+
import { sanitizeConfig } from './utils/config';
|
|
12
|
+
|
|
13
|
+
// Model
|
|
14
|
+
import * as model from './model';
|
|
15
|
+
|
|
16
|
+
// Views
|
|
17
|
+
import { PdcLocationView } from './views';
|
|
18
|
+
import { PdcExpenseView } from './views';
|
|
19
|
+
customElements.define('pdc-expense-view', PdcExpenseView);
|
|
20
|
+
customElements.define('pdc-location-view', PdcLocationView);
|
|
21
|
+
|
|
22
|
+
export class Pdc {
|
|
23
|
+
/* SETUP
|
|
24
|
+
*/
|
|
25
|
+
#container: Element;
|
|
26
|
+
#config: Config;
|
|
27
|
+
#viewLocation;
|
|
28
|
+
#viewExpense;
|
|
29
|
+
#styled;
|
|
30
|
+
#eventTarget;
|
|
31
|
+
|
|
32
|
+
constructor(container: Element, configUser: Partial<Config> | null = null) {
|
|
33
|
+
this.#container = container;
|
|
34
|
+
this.#config = sanitizeConfig(configUser);
|
|
35
|
+
this.#styled = this.#config.styled;
|
|
36
|
+
|
|
37
|
+
this.#viewLocation = new PdcLocationView(
|
|
38
|
+
this.#styled,
|
|
39
|
+
this.#config.location,
|
|
40
|
+
);
|
|
41
|
+
this.#viewExpense = new PdcExpenseView();
|
|
42
|
+
|
|
43
|
+
this.#viewLocation.controllerHandler(
|
|
44
|
+
this.#locationUpdated,
|
|
45
|
+
this.#locationDeleted,
|
|
46
|
+
this.#locationsValidate,
|
|
47
|
+
);
|
|
48
|
+
this.#eventTarget = new EventTarget();
|
|
49
|
+
|
|
50
|
+
this.#container.insertAdjacentElement('afterbegin', this.#viewLocation);
|
|
51
|
+
this.#container.insertAdjacentElement('beforeend', this.#viewExpense);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#dispatchEvent() {
|
|
55
|
+
const event = new CustomEvent('expenseUpdate', {
|
|
56
|
+
detail: {
|
|
57
|
+
data: model.returnExpenses(),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
this.#eventTarget.dispatchEvent(event);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
addEventListener(eventName: 'expenseUpdate', callback: EventListener) {
|
|
64
|
+
this.#eventTarget.addEventListener(eventName, callback);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
removeEventListener(eventName: 'expenseUpdate', callback: EventListener) {
|
|
68
|
+
this.#eventTarget.removeEventListener(eventName, callback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* LOCATION
|
|
72
|
+
*/
|
|
73
|
+
#locationDeleted = async (
|
|
74
|
+
updatedRows: StateLocationItem[],
|
|
75
|
+
): Promise<void> => {
|
|
76
|
+
this.#viewExpense.renderEmtpy();
|
|
77
|
+
model.updateAllStateLocations(updatedRows);
|
|
78
|
+
const row = updatedRows[updatedRows.length - 1];
|
|
79
|
+
const { index, start, end, category, country, city } = row;
|
|
80
|
+
if (!city && country) {
|
|
81
|
+
this.#viewLocation.restrictRow(index, 'country');
|
|
82
|
+
await this.#createSelectOptions(row);
|
|
83
|
+
}
|
|
84
|
+
if (!country && category) {
|
|
85
|
+
this.#viewLocation.restrictRow(index, 'category');
|
|
86
|
+
await this.#createSelectOptions(row);
|
|
87
|
+
}
|
|
88
|
+
if (!end && start) this.#viewLocation.restrictRow(index, 'end');
|
|
89
|
+
if (start && !end) this.#viewLocation.restrictRow(index, 'start');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
#locationUpdated = async (
|
|
93
|
+
row: StateLocationItem,
|
|
94
|
+
changedAttr: LocationKeys,
|
|
95
|
+
newValue: string | null,
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
const { index, start, end, category, country, city } = row;
|
|
98
|
+
this.#viewExpense.renderEmtpy();
|
|
99
|
+
model.updateStateLocation(row);
|
|
100
|
+
let startDate, endDate;
|
|
101
|
+
switch (true) {
|
|
102
|
+
case !newValue && !!country && !!category && !!end && !!start:
|
|
103
|
+
this.#viewLocation.restrictRow(index, 'country');
|
|
104
|
+
await this.#createSelectOptions(row);
|
|
105
|
+
return;
|
|
106
|
+
case !newValue && !!category && !!end && !!start:
|
|
107
|
+
this.#viewLocation.restrictRow(index, 'category');
|
|
108
|
+
await this.#createSelectOptions(row);
|
|
109
|
+
return;
|
|
110
|
+
case !newValue && !!end && !!start:
|
|
111
|
+
this.#viewLocation.restrictRow(index, 'end');
|
|
112
|
+
return;
|
|
113
|
+
case !newValue:
|
|
114
|
+
this.#viewLocation.restrictRow(index, 'start');
|
|
115
|
+
return;
|
|
116
|
+
case (changedAttr === 'start' || changedAttr === 'end') && // To account for when rows are deleted and the only updates are to the start/end dates of prev/next rows
|
|
117
|
+
!!start &&
|
|
118
|
+
!!end &&
|
|
119
|
+
!!category &&
|
|
120
|
+
!!country &&
|
|
121
|
+
!!city:
|
|
122
|
+
startDate = new Date(start);
|
|
123
|
+
endDate = new Date(end);
|
|
124
|
+
if (startDate <= endDate) {
|
|
125
|
+
return;
|
|
126
|
+
} else {
|
|
127
|
+
this.#viewLocation.restrictRow(index, changedAttr);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
default:
|
|
131
|
+
this.#viewLocation.restrictRow(index, changedAttr);
|
|
132
|
+
if (changedAttr === 'category' || changedAttr === 'country') {
|
|
133
|
+
await this.#createSelectOptions(row);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
#createSelectOptions = async (row: StateLocationItem) => {
|
|
140
|
+
const { index } = row;
|
|
141
|
+
const locationCategory = row.country ? 'city' : 'country';
|
|
142
|
+
this.#viewLocation.showLoadingSpinner(index, true, locationCategory);
|
|
143
|
+
const list = await model.returnOptions(row);
|
|
144
|
+
this.#viewLocation.createOptions(index, list, locationCategory);
|
|
145
|
+
this.#viewLocation.showLoadingSpinner(index, false, locationCategory);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
#locationsValidate = (viewValidator: AllViewLocationsValid): void => {
|
|
149
|
+
this.#viewExpense.renderEmtpy();
|
|
150
|
+
if (!viewValidator.valid) return;
|
|
151
|
+
if (!model.validateStateLocations()) return;
|
|
152
|
+
this.#createExpenses(viewValidator);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/* EXPENSE
|
|
156
|
+
*/
|
|
157
|
+
async #createExpenses(viewValidator: AllViewLocationsValid) {
|
|
158
|
+
try {
|
|
159
|
+
this.#viewExpense.render(this.#styled, this.#config.expense);
|
|
160
|
+
this.#viewExpense.renderLoadingSpinner(true);
|
|
161
|
+
this.#viewExpense.controllerHandler(
|
|
162
|
+
this.#expenseUpdated,
|
|
163
|
+
this.#expenseTable,
|
|
164
|
+
);
|
|
165
|
+
const expenses = await model.generateExpenses(viewValidator);
|
|
166
|
+
await this.#viewExpense.addRows(
|
|
167
|
+
expenses,
|
|
168
|
+
viewValidator.expensesCategory,
|
|
169
|
+
);
|
|
170
|
+
this.#viewExpense.renderLoadingSpinner(false);
|
|
171
|
+
this.#dispatchEvent();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#expenseUpdated = (row: StateExpenseItemUpdate): void => {
|
|
178
|
+
const { date, newRowMieTotal, totalMie, totalLodging } =
|
|
179
|
+
model.updateStateExpenseItem(row);
|
|
180
|
+
this.#viewExpense.updateRowMie(
|
|
181
|
+
date,
|
|
182
|
+
newRowMieTotal,
|
|
183
|
+
totalMie,
|
|
184
|
+
totalLodging,
|
|
185
|
+
);
|
|
186
|
+
this.#viewExpense.emptyExpenseTable();
|
|
187
|
+
this.#dispatchEvent();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
#expenseTable = (): void => {
|
|
191
|
+
this.#viewExpense.createExpenseTable(model.returnExpenses());
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/ts/model.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
import type {
|
|
3
|
+
AllViewLocationsValid,
|
|
4
|
+
Location,
|
|
5
|
+
StateLocationItem,
|
|
6
|
+
} from './types/locations';
|
|
7
|
+
import type {
|
|
8
|
+
StateExpenseItemUpdate,
|
|
9
|
+
StateExpenseItemValid,
|
|
10
|
+
} from './types/expenses';
|
|
11
|
+
import type { State } from './types/config';
|
|
12
|
+
|
|
13
|
+
// Utils
|
|
14
|
+
import {
|
|
15
|
+
getCountriesDomestic,
|
|
16
|
+
getCountriesIntl,
|
|
17
|
+
getCitiesDomestic,
|
|
18
|
+
getCitiesIntl,
|
|
19
|
+
returnValidStateLocation,
|
|
20
|
+
} from './utils/locations';
|
|
21
|
+
import { createExpenseObjs, getMieRates } from './utils/expenses';
|
|
22
|
+
import {
|
|
23
|
+
getLodgingRateDomestic,
|
|
24
|
+
getLodgingRateIntl,
|
|
25
|
+
returnValidStateExpense,
|
|
26
|
+
} from './utils/expenses';
|
|
27
|
+
import { US_STATE_LENGTH } from './utils/config';
|
|
28
|
+
|
|
29
|
+
const state: State = {
|
|
30
|
+
locations: [],
|
|
31
|
+
locationsValid: [],
|
|
32
|
+
expenses: [],
|
|
33
|
+
expensesValid: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const updateAllStateLocations = (
|
|
37
|
+
updatedRows: StateLocationItem[],
|
|
38
|
+
): void => {
|
|
39
|
+
state.locations = updatedRows;
|
|
40
|
+
// logStateLocation();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const updateStateLocation = (
|
|
44
|
+
updatedLocation: StateLocationItem,
|
|
45
|
+
): void => {
|
|
46
|
+
state.locations[updatedLocation.index] = updatedLocation;
|
|
47
|
+
// logStateLocation();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Function to log every time state is updated during development
|
|
51
|
+
// const logStateLocation = () => {
|
|
52
|
+
// console.table(state.locations, [
|
|
53
|
+
// 'start',
|
|
54
|
+
// 'end',
|
|
55
|
+
// 'category',
|
|
56
|
+
// 'country',
|
|
57
|
+
// 'city',
|
|
58
|
+
// ]);
|
|
59
|
+
// }
|
|
60
|
+
|
|
61
|
+
export const returnOptions = async (
|
|
62
|
+
row: StateLocationItem,
|
|
63
|
+
): Promise<Location[]> => {
|
|
64
|
+
const { index, end, category, country } = row;
|
|
65
|
+
const listType = country ? 'city' : 'country';
|
|
66
|
+
if (!end) throw new Error(`Location ${index} - no end date`);
|
|
67
|
+
const list =
|
|
68
|
+
country ?
|
|
69
|
+
country.length === US_STATE_LENGTH ?
|
|
70
|
+
// Domestic countries are all state abbreviations with length of 2 (e.g. 'NY')
|
|
71
|
+
await getCitiesDomestic(end, country)
|
|
72
|
+
: await getCitiesIntl(end, country)
|
|
73
|
+
: category === 'domestic' ? await getCountriesDomestic(end)
|
|
74
|
+
: await getCountriesIntl(end);
|
|
75
|
+
if (list.length === 0)
|
|
76
|
+
throw new Error(`Failed to get ${listType} list for ${end}`);
|
|
77
|
+
return list;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const validateStateLocations = (): boolean => {
|
|
81
|
+
state.locationsValid.length = 0;
|
|
82
|
+
state.locations.forEach(location => {
|
|
83
|
+
const validLocation = returnValidStateLocation(location);
|
|
84
|
+
if (validLocation) state.locationsValid.push(validLocation);
|
|
85
|
+
});
|
|
86
|
+
return state.locations.length === state.locationsValid.length;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const generateExpenses = async (
|
|
90
|
+
viewValidator: AllViewLocationsValid,
|
|
91
|
+
) => {
|
|
92
|
+
const { expensesCategory } = viewValidator;
|
|
93
|
+
state.expenses.length = 0;
|
|
94
|
+
state.locationsValid.map(location =>
|
|
95
|
+
state.expenses.push(...createExpenseObjs(location)),
|
|
96
|
+
);
|
|
97
|
+
const expensesWithSomeRates = await Promise.all(
|
|
98
|
+
state.expenses.map(expense => {
|
|
99
|
+
return expense.country.length === US_STATE_LENGTH ?
|
|
100
|
+
getLodgingRateDomestic(expense)
|
|
101
|
+
: getLodgingRateIntl(expense);
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const expensesWithAllRates = await Promise.all(
|
|
105
|
+
expensesWithSomeRates.map(expense => {
|
|
106
|
+
return getMieRates(expensesCategory, expense);
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
state.expensesValid = expensesWithAllRates.map(expense => {
|
|
110
|
+
return returnValidStateExpense(expense);
|
|
111
|
+
});
|
|
112
|
+
return state.expensesValid;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const updateStateExpenseItem = (update: StateExpenseItemUpdate) => {
|
|
116
|
+
const { date, lodgingAmount, ...deductions } = update;
|
|
117
|
+
const item = state.expensesValid.find(expense => expense.date === date);
|
|
118
|
+
if (!item) throw new Error(`Failed to find expense matching the update.`);
|
|
119
|
+
item.lodgingAmount = lodgingAmount;
|
|
120
|
+
item.deductions = {
|
|
121
|
+
...item.deductions,
|
|
122
|
+
...deductions,
|
|
123
|
+
};
|
|
124
|
+
if (item.mieAmount > 0) updateExpenseMie(item, update);
|
|
125
|
+
item.totalAmount = item.lodgingAmount + item.mieAmount;
|
|
126
|
+
const { totalMie, totalLodging } = getExpenseSubtotals();
|
|
127
|
+
return {
|
|
128
|
+
date: item.date,
|
|
129
|
+
newRowMieTotal: item.mieAmount,
|
|
130
|
+
totalMie,
|
|
131
|
+
totalLodging,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const updateExpenseMie = (
|
|
136
|
+
item: StateExpenseItemValid,
|
|
137
|
+
update: StateExpenseItemUpdate,
|
|
138
|
+
) => {
|
|
139
|
+
let total =
|
|
140
|
+
item.deductions.FirstLastDay ?
|
|
141
|
+
item.rates.maxMieFirstLast
|
|
142
|
+
: item.rates.maxMie;
|
|
143
|
+
if (update.breakfastProvided) total -= item.rates.deductionBreakfast;
|
|
144
|
+
if (update.lunchProvided) total -= item.rates.deductionLunch;
|
|
145
|
+
if (update.dinnerProvided) total -= item.rates.deductionDinner;
|
|
146
|
+
if (total < item.rates.maxIncidental) total = item.rates.maxIncidental;
|
|
147
|
+
item.mieAmount = total;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const getExpenseSubtotals = () => {
|
|
151
|
+
return state.expensesValid.reduce(
|
|
152
|
+
(result, item) => {
|
|
153
|
+
result.totalMie += item.mieAmount;
|
|
154
|
+
result.totalLodging += item.lodgingAmount;
|
|
155
|
+
return result;
|
|
156
|
+
},
|
|
157
|
+
{ totalMie: 0, totalLodging: 0 },
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const returnExpenses = () => {
|
|
162
|
+
return state.expensesValid;
|
|
163
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StateLocationItem, StateLocationItemValid } from './locations';
|
|
2
|
+
import type { StateExpenseItem, StateExpenseItemValid } from './expenses';
|
|
3
|
+
|
|
4
|
+
export interface Config {
|
|
5
|
+
styled: boolean;
|
|
6
|
+
location: ConfigSectionText;
|
|
7
|
+
expense: ConfigSectionText;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ConfigSectionText {
|
|
11
|
+
heading?: string;
|
|
12
|
+
headingPrint?: string;
|
|
13
|
+
body?: string;
|
|
14
|
+
bodyPrint?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface State {
|
|
18
|
+
locations: StateLocationItem[];
|
|
19
|
+
locationsValid: StateLocationItemValid[];
|
|
20
|
+
expenses: StateExpenseItem[];
|
|
21
|
+
expensesValid: StateExpenseItemValid[];
|
|
22
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type YYYY =
|
|
2
|
+
| '2021'
|
|
3
|
+
| '2022'
|
|
4
|
+
| '2023'
|
|
5
|
+
| '2024'
|
|
6
|
+
| '2025'
|
|
7
|
+
| '2026'
|
|
8
|
+
| '2027'
|
|
9
|
+
| '2028'
|
|
10
|
+
| '2029'
|
|
11
|
+
| '2030'
|
|
12
|
+
| '2031'
|
|
13
|
+
| '2032'
|
|
14
|
+
| '2033'
|
|
15
|
+
| '2034'
|
|
16
|
+
| '2035'
|
|
17
|
+
| '2036'
|
|
18
|
+
| '2037'
|
|
19
|
+
| '2038'
|
|
20
|
+
| '2039'
|
|
21
|
+
| '2040';
|
|
22
|
+
|
|
23
|
+
type MM =
|
|
24
|
+
| '01'
|
|
25
|
+
| '02'
|
|
26
|
+
| '03'
|
|
27
|
+
| '04'
|
|
28
|
+
| '05'
|
|
29
|
+
| '06'
|
|
30
|
+
| '07'
|
|
31
|
+
| '08'
|
|
32
|
+
| '09'
|
|
33
|
+
| '10'
|
|
34
|
+
| '11'
|
|
35
|
+
| '12';
|
|
36
|
+
type DD =
|
|
37
|
+
| '01'
|
|
38
|
+
| '02'
|
|
39
|
+
| '03'
|
|
40
|
+
| '04'
|
|
41
|
+
| '05'
|
|
42
|
+
| '06'
|
|
43
|
+
| '07'
|
|
44
|
+
| '08'
|
|
45
|
+
| '09'
|
|
46
|
+
| '10'
|
|
47
|
+
| '11'
|
|
48
|
+
| '12'
|
|
49
|
+
| '13'
|
|
50
|
+
| '14'
|
|
51
|
+
| '15'
|
|
52
|
+
| '16'
|
|
53
|
+
| '17'
|
|
54
|
+
| '18'
|
|
55
|
+
| '19'
|
|
56
|
+
| '20'
|
|
57
|
+
| '21'
|
|
58
|
+
| '22'
|
|
59
|
+
| '23'
|
|
60
|
+
| '24'
|
|
61
|
+
| '25'
|
|
62
|
+
| '26'
|
|
63
|
+
| '27'
|
|
64
|
+
| '28'
|
|
65
|
+
| '29'
|
|
66
|
+
| '30'
|
|
67
|
+
| '31';
|
|
68
|
+
|
|
69
|
+
export type DateRaw = `${YYYY}-${MM}-${DD}`;
|
|
70
|
+
export type ShortMonth =
|
|
71
|
+
| 'Jan'
|
|
72
|
+
| 'Feb'
|
|
73
|
+
| 'Mar'
|
|
74
|
+
| 'Apr'
|
|
75
|
+
| 'May'
|
|
76
|
+
| 'Jun'
|
|
77
|
+
| 'Jul'
|
|
78
|
+
| 'Aug'
|
|
79
|
+
| 'Sep'
|
|
80
|
+
| 'Oct'
|
|
81
|
+
| 'Nov'
|
|
82
|
+
| 'Dec';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { DateRaw } from './dates';
|
|
2
|
+
|
|
3
|
+
// Format of lodging rate data coming from GSA
|
|
4
|
+
export interface RateLodging {
|
|
5
|
+
Jan: string;
|
|
6
|
+
Feb: string;
|
|
7
|
+
Mar: string;
|
|
8
|
+
Apr: string;
|
|
9
|
+
May: string;
|
|
10
|
+
Jun: string;
|
|
11
|
+
Jul: string;
|
|
12
|
+
Aug: string;
|
|
13
|
+
Sep: string;
|
|
14
|
+
Oct: string;
|
|
15
|
+
Nov: string;
|
|
16
|
+
Meals: number;
|
|
17
|
+
City: string;
|
|
18
|
+
State: string;
|
|
19
|
+
County: string | null;
|
|
20
|
+
DID: number;
|
|
21
|
+
Dec: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Format of meals rate data coming from GSA
|
|
25
|
+
export interface RateMeals {
|
|
26
|
+
total: number;
|
|
27
|
+
breakfast: number;
|
|
28
|
+
lunch: number;
|
|
29
|
+
dinner: number;
|
|
30
|
+
incidental: number;
|
|
31
|
+
FirstLastDay: number;
|
|
32
|
+
max?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ExpenseDeductions {
|
|
36
|
+
FirstLastDay: boolean;
|
|
37
|
+
breakfastProvided: boolean;
|
|
38
|
+
lunchProvided: boolean;
|
|
39
|
+
dinnerProvided: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExpenseRates {
|
|
43
|
+
maxLodging: number;
|
|
44
|
+
maxMie: number;
|
|
45
|
+
maxMieFirstLast?: number;
|
|
46
|
+
deductionBreakfast?: number;
|
|
47
|
+
deductionLunch?: number;
|
|
48
|
+
deductionDinner?: number;
|
|
49
|
+
maxIncidental?: number;
|
|
50
|
+
effDate: DateRaw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StateExpenseItem {
|
|
54
|
+
date: DateRaw;
|
|
55
|
+
country: string;
|
|
56
|
+
city: string;
|
|
57
|
+
deductions: ExpenseDeductions;
|
|
58
|
+
rates?: ExpenseRates;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type StateExpenseItemInclRates = StateExpenseItem & {
|
|
62
|
+
rates: Required<ExpenseRates>;
|
|
63
|
+
lodgingAmount: number;
|
|
64
|
+
mieAmount: number;
|
|
65
|
+
totalAmount: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type StateExpenseItemValid = Required<StateExpenseItemInclRates> & {
|
|
69
|
+
source: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type StateExpenseItemUpdate = Omit<ExpenseDeductions, 'FirstLastDay'> &
|
|
73
|
+
Pick<StateExpenseItemValid, 'date' | 'lodgingAmount'>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DateRaw } from './dates';
|
|
2
|
+
|
|
3
|
+
export interface Location {
|
|
4
|
+
city?: string;
|
|
5
|
+
country?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
category?: 'domestic' | 'intl';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AllViewLocationsValid {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
expensesCategory: 'mie' | 'lodging' | 'both';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type StateLocationItem = Omit<Location, 'label'> & {
|
|
16
|
+
index: number;
|
|
17
|
+
start?: DateRaw;
|
|
18
|
+
end?: DateRaw;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LocationKeys = keyof Omit<StateLocationItem, 'index'>;
|
|
22
|
+
|
|
23
|
+
export type StateLocationItemValid = Required<
|
|
24
|
+
Omit<StateLocationItem, 'index' | 'category'>
|
|
25
|
+
>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
import type { Config } from '../../types/config';
|
|
3
|
+
|
|
4
|
+
export const configDefault: Config = {
|
|
5
|
+
styled: true,
|
|
6
|
+
location: {
|
|
7
|
+
heading: 'Trip Details',
|
|
8
|
+
},
|
|
9
|
+
expense: {
|
|
10
|
+
heading: 'Expenses',
|
|
11
|
+
body: 'Confirm lodging amount and meals provided for each day',
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { configDefault } from './configDefault';
|
|
2
|
+
export {
|
|
3
|
+
ROW_CLOSED_HEIGHT,
|
|
4
|
+
DEBOUNCE_TIME,
|
|
5
|
+
BTN_ANIMATE_MS,
|
|
6
|
+
ROW_ANIMATE_MS,
|
|
7
|
+
US_STATE_LENGTH,
|
|
8
|
+
MILLISECONDS_IN_DAY,
|
|
9
|
+
APPROX_DAYS_IN_6_MONTHS,
|
|
10
|
+
OCTOBER,
|
|
11
|
+
} from './numbers';
|
|
12
|
+
export { sanitizeConfig } from './sanitizeConfig';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const ROW_CLOSED_HEIGHT = 96;
|
|
2
|
+
export const DEBOUNCE_TIME = 150;
|
|
3
|
+
export const BTN_ANIMATE_MS = 450;
|
|
4
|
+
export const ROW_ANIMATE_MS = 800;
|
|
5
|
+
export const US_STATE_LENGTH = 'NY'.length;
|
|
6
|
+
export const OCTOBER = 10;
|
|
7
|
+
|
|
8
|
+
const MILLISECONDS_IN_SECOND = 1000;
|
|
9
|
+
const SECONDS_IN_MINUTE = 60;
|
|
10
|
+
const MINUTES_IN_HOUR = 60;
|
|
11
|
+
const HOURS_IN_DAY = 24;
|
|
12
|
+
export const MILLISECONDS_IN_DAY =
|
|
13
|
+
MILLISECONDS_IN_SECOND * SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY;
|
|
14
|
+
|
|
15
|
+
const AVERAGE_DAYS_IN_YEAR = 365.25;
|
|
16
|
+
const MONTHS_IN_YEAR = 12;
|
|
17
|
+
const MONTHS_IN_DURATION = 6;
|
|
18
|
+
|
|
19
|
+
// Calculate the average days in a month
|
|
20
|
+
const AVERAGE_DAYS_IN_MONTH = AVERAGE_DAYS_IN_YEAR / MONTHS_IN_YEAR;
|
|
21
|
+
|
|
22
|
+
// The constant for approximate days in 6 months
|
|
23
|
+
export const APPROX_DAYS_IN_6_MONTHS =
|
|
24
|
+
AVERAGE_DAYS_IN_MONTH * MONTHS_IN_DURATION;
|