diff --git a/README.md b/README.md index 10307b47..da89c089 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Ensure the following packages are installed. ### Development - Create a file named `.env` at project root with the following in it. You can change the `USER_EDIPI` value to any -number greater than 1 to create a new user. +10 digit number greater than 1 to create a new user. ``` -USER_EDIPI=1 +USER_EDIPI=0000000001 ``` - Navigate to the project root and run the following. @@ -70,8 +70,8 @@ where `{name}` is the name of the migration you want to create. An empty templat #### Restoring State Locally -If your local database schema falls out of sync with migrations and you want to get back to a clean state, you can -run `npm run seed-dev`. This will automatically recreate the database, apply the current migrations, and get the app back +If your local database schema falls out of sync with migrations and you want to get back to a clean state, you can +run `npm run seed-dev`. This will automatically recreate the database, apply the current migrations, and get the app back into a usable state. ### Testing diff --git a/package-lock.json b/package-lock.json index 061e2ba0..46958cb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2632,6 +2632,11 @@ "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", "dev": true }, + "@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==" + }, "@types/yargs": { "version": "13.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.10.tgz", @@ -12692,6 +12697,14 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, + "moment-timezone": { + "version": "0.5.32", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.32.tgz", + "integrity": "sha512-Z8QNyuQHQAmWucp8Knmgei8YNo28aLjJq6Ma+jy1ZSpSk5nyfRT8xgUbSQvD2+2UajISfenndwvFuH3NGS+nvA==", + "requires": { + "moment": ">= 2.9.0" + } + }, "monotone-convex-hull-2d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/monotone-convex-hull-2d/-/monotone-convex-hull-2d-1.0.1.tgz", @@ -12964,6 +12977,13 @@ "requires": { "ecdsa-sig-formatter": "^1.0.5", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "no-case": { @@ -17083,6 +17103,11 @@ "psl": "^1.1.28", "punycode": "^2.1.1" } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -18082,6 +18107,11 @@ "websocket-driver": ">=0.5.1" } }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, "websocket-driver": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz", @@ -20013,9 +20043,9 @@ } }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha1-sj5DWK+oogL+ehAK8fX4g/AgB+4=" + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==" }, "v8-compile-cache": { "version": "2.1.1", @@ -20694,6 +20724,13 @@ "requires": { "ansi-colors": "^3.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "webpack-manifest-plugin": { diff --git a/package.json b/package.json index a587ebd4..75d5d91b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/dotenv": "^8.2.0", "@types/http-proxy-middleware": "^0.19.3", "@types/multer": "^1.4.4", + "@types/uuid": "^8.3.0", "axios": "^0.20.0", "axios-cookiejar-support": "^1.0.0", "axios-retry": "^3.1.9", @@ -35,6 +36,7 @@ "json-2-csv": "^3.7.8", "lodash": "^4.17.20", "moment": "^2.29.1", + "moment-timezone": "^0.5.32", "morgan": "^1.10.0", "multer": "^1.4.2", "njwt": "^1.0.0", @@ -55,7 +57,8 @@ "tough-cookie": "^4.0.0", "ts-md5": "^1.2.7", "typeorm": "^0.2.25", - "typeorm-naming-strategies": "^2.0.0" + "typeorm-naming-strategies": "^2.0.0", + "uuid": "^8.3.1" }, "devDependencies": { "@testing-library/jest-dom": "^4.2.4", diff --git a/server/api/index.ts b/server/api/index.ts index e360c3bf..87850286 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -4,6 +4,7 @@ import orgRoutes from './org'; import userRoutes from './user'; import roleRoutes from './role'; import rosterRoutes from './roster'; +import unitRoutes from './unit'; import accessRequestRoutes from './access-request'; import workspaceRoutes from './workspace'; import notificationRoutes from './notification'; @@ -19,6 +20,7 @@ router.use('/org', orgRoutes); router.use('/user', userRoutes); router.use('/role', roleRoutes); router.use('/roster', rosterRoutes); +router.use('/unit', unitRoutes); router.use('/access-request', accessRequestRoutes); router.use('/workspace', workspaceRoutes); router.use('/notification', notificationRoutes); @@ -56,6 +58,10 @@ export type WorkspaceParam = { workspaceId: string }; +export type UnitParam = { + unitId: string +}; + export type SettingParam = { settingId: string }; @@ -68,6 +74,7 @@ export type OrgRoleParams = OrgParam & RoleParam; export type OrgEdipiParams = OrgParam & EdipiParam; export type OrgRosterParams = OrgParam & RosterParam; export type OrgWorkspaceParams = OrgParam & WorkspaceParam; +export type OrgUnitParams = OrgParam & UnitParam; export type OrgSettingParams = OrgParam & SettingParam; export type OrgColumnNameParams = OrgParam & ColumnNameParam; diff --git a/server/api/muster/muster.controller.ts b/server/api/muster/muster.controller.ts index 94485fdb..2420930a 100644 --- a/server/api/muster/muster.controller.ts +++ b/server/api/muster/muster.controller.ts @@ -1,5 +1,4 @@ import moment, { unitOfTime } from 'moment'; -import _ from 'lodash'; import { MSearchResponse, SearchResponse } from 'elasticsearch'; import { Response } from 'express'; import { getConnection } from 'typeorm'; @@ -11,15 +10,11 @@ import { } from '../index'; import { Org } from '../org/org.model'; import { Role } from '../role/role.model'; -import { getAllowedRosterColumns, RosterEntryData } from '../roster/roster.controller'; +import { getAllowedRosterColumns } from '../roster/roster.controller'; import { Roster } from '../roster/roster.model'; const dateFormat = 'YYYY-MM-DD'; -function unitNameToId(unitName: string) { - return _.camelCase(unitName); -} - class MusterController { // TODO: Support custom muster intervals. @@ -28,7 +23,7 @@ class MusterController { const limit = parseInt(req.query.limit ?? '10'); const page = parseInt(req.query.page ?? '0'); - const individuals = await getIndividualsData(req.appOrg!, req.appRole!, intervalCount, req.query.unit); + const individuals = await getIndividualsData(req.appOrg!, req.appRole!, intervalCount, req.query.unitId); const offset = page * limit; @@ -41,7 +36,7 @@ class MusterController { async exportIndividuals(req: ApiRequest, res: Response) { const intervalCount = parseInt(req.query.intervalCount ?? '1'); - const individuals = await getIndividualsData(req.appOrg!, req.appRole!, intervalCount, req.query.unit); + const individuals = await getIndividualsData(req.appOrg!, req.appRole!, intervalCount, req.query.unitId); // Delete roster ids since they're meaningless to the user. for (const individual of individuals) { @@ -67,24 +62,21 @@ class MusterController { // Get unique dates and unit names. const weeklyDates = new Set(); const monthlyDates = new Set(); - const unitNames = new Set(); - const unitNameIds = new Set(); + const unitIds = new Set(); for (const date of Object.keys(unitRosterCounts.weekly)) { weeklyDates.add(date); - for (const unitName of Object.keys(unitRosterCounts.weekly[date])) { - unitNames.add(unitName); - unitNameIds.add(unitNameToId(unitName)); + for (const unitId of Object.keys(unitRosterCounts.weekly[date])) { + unitIds.add(unitId); } } for (const date of Object.keys(unitRosterCounts.monthly)) { monthlyDates.add(date); - for (const unitName of Object.keys(unitRosterCounts.monthly[date])) { - unitNames.add(unitName); - unitNameIds.add(unitNameToId(unitName)); + for (const unitId of Object.keys(unitRosterCounts.monthly[date])) { + unitIds.add(unitId); } } @@ -93,37 +85,23 @@ class MusterController { // const esBody = [] as any[]; - // Weekly ES Query - esBody.push({ - index: req.appRole!.getKibanaIndexForMuster(), - }); - - const esWeeklyBody = { - size: 0, - query: { - bool: { - must: [{ - range: { - Timestamp: { - gte: `now-${weeksCount}w/w`, - lt: 'now/w', + // Weekly ES Queries + for (const unitId of unitIds) { + esBody.push({ + index: req.appRole!.getKibanaIndexForMuster(unitId), + }); + esBody.push({ + size: 0, + query: { + bool: { + must: [{ + range: { + Timestamp: { + gte: `now-${weeksCount}w/w`, + lt: 'now/w', + }, }, - }, - }], - }, - }, - aggs: {}, - } as any; - - const unitWeeklyAggNameList = []; - for (const unitName of unitNames) { - const unitNameId = unitNameToId(unitName); - unitWeeklyAggNameList.push(unitNameId); - - esWeeklyBody.aggs[unitNameId] = { - filter: { - term: { - 'Roster.unit.keyword': unitName, + }], }, }, aggs: { @@ -134,42 +112,26 @@ class MusterController { }, }, }, - }; + }); } - esBody.push(esWeeklyBody); - - // Montly ES Query - esBody.push({ - index: req.appRole!.getKibanaIndexForMuster(), - }); - - const esMonthlyBody = { - size: 0, - query: { - bool: { - must: [{ - range: { - Timestamp: { - gte: `now-${monthsCount}M/M`, - lt: 'now/M', + // Monthly ES Queries + for (const unitId of unitIds) { + esBody.push({ + index: req.appRole!.getKibanaIndexForMuster(unitId), + }); + esBody.push({ + size: 0, + query: { + bool: { + must: [{ + range: { + Timestamp: { + gte: `now-${monthsCount}M/M`, + lt: 'now/M', + }, }, - }, - }], - }, - }, - aggs: {}, - } as any; - - const unitMonthlyAggNameList = []; - for (const unitName of unitNames) { - const unitNameId = unitNameToId(unitName); - unitMonthlyAggNameList.push(unitNameId); - - esMonthlyBody.aggs[unitNameId] = { - filter: { - term: { - 'Roster.unit.keyword': unitName, + }], }, }, aggs: { @@ -180,11 +142,9 @@ class MusterController { }, }, }, - }; + }); } - esBody.push(esMonthlyBody); - // Send request. let response: MSearchResponse; try { @@ -198,7 +158,7 @@ class MusterController { // type UnitStatsByDate = { [date: string]: { - [unitName: string]: { + [unitId: string]: { nonMusterPercent: number, reportsCount: number rosterCount: number @@ -216,37 +176,47 @@ class MusterController { unitStats.weekly[date] = {}; } - for (const unitName of unitNames) { - const unitNameId = unitNameToId(unitName); - const buckets = response.responses![0].aggregations[unitNameId].reportsHistogram.buckets as { + let responseIndex = 0; + for (const unitId of unitIds) { + let buckets: { key_as_string: string key: number doc_count: number }[]; + if (!response.responses![responseIndex].aggregations) { + buckets = []; + } else { + buckets = response.responses![responseIndex].aggregations.reportsHistogram.buckets as { + key_as_string: string + key: number + doc_count: number + }[]; + } for (const bucket of buckets) { const date = moment.utc(bucket.key).format(dateFormat); const reportsCount = bucket.doc_count; - const rosterCount = unitRosterCounts.weekly[date][unitName]; + const rosterCount = unitRosterCounts.weekly[date][unitId]; const nextWeek = moment.utc(date).add(1, 'week'); const maxReportsCount = rosterCount * nextWeek.diff(date, 'days'); - unitStats.weekly[date][unitName] = { + unitStats.weekly[date][unitId] = { nonMusterPercent: calcNonMusterPercent(reportsCount, maxReportsCount), rosterCount, reportsCount, }; } + responseIndex += 1; } // Any units that weren't found must not have any reports. Add them manually. for (const date of weeklyDates) { - for (const unitName of unitNames) { - if (!unitStats.weekly[date][unitName]) { - unitStats.weekly[date][unitName] = { + for (const unitId of unitIds) { + if (!unitStats.weekly[date][unitId]) { + unitStats.weekly[date][unitId] = { nonMusterPercent: 100, - rosterCount: unitRosterCounts.weekly[date][unitName], + rosterCount: unitRosterCounts.weekly[date][unitId], reportsCount: 0, }; } @@ -258,37 +228,46 @@ class MusterController { unitStats.monthly[date] = {}; } - for (const unitName of unitNames) { - const unitNameId = unitNameToId(unitName); - const buckets = response.responses![1].aggregations[unitNameId].reportsHistogram.buckets as { + for (const unitId of unitIds) { + let buckets: { key_as_string: string key: number doc_count: number }[]; + if (!response.responses![responseIndex].aggregations) { + buckets = []; + } else { + buckets = response.responses![responseIndex].aggregations.reportsHistogram.buckets as { + key_as_string: string + key: number + doc_count: number + }[]; + } for (const bucket of buckets) { const date = moment.utc(bucket.key).format(dateFormat); const reportsCount = bucket.doc_count; - const rosterCount = unitRosterCounts.monthly[date][unitName]; + const rosterCount = unitRosterCounts.monthly[date][unitId]; const nextMonth = moment.utc(date).add(1, 'month'); const maxReportsCount = rosterCount * nextMonth.diff(date, 'days'); - unitStats.monthly[date][unitName] = { + unitStats.monthly[date][unitId] = { nonMusterPercent: calcNonMusterPercent(reportsCount, maxReportsCount), rosterCount, reportsCount, }; } + responseIndex += 1; } // Any units that weren't found must not have any reports. Add them manually. for (const date of monthlyDates) { - for (const unitName of unitNames) { - if (!unitStats.monthly[date][unitName]) { - unitStats.monthly[date][unitName] = { + for (const unitId of unitIds) { + if (!unitStats.monthly[date][unitId]) { + unitStats.monthly[date][unitId] = { nonMusterPercent: 100, - rosterCount: unitRosterCounts.monthly[date][unitName], + rosterCount: unitRosterCounts.monthly[date][unitId], reportsCount: 0, }; } @@ -304,22 +283,24 @@ class MusterController { // Helpers // -async function getIndividualsData(org: Org, role: Role, intervalCount: number, unit?: string) { +async function getIndividualsData(org: Org, role: Role, intervalCount: number, unitId?: string) { // HACK: The database queries in this function are extremely inefficient for large rosters, and need to be revised // once the new elasticsearch muster data architecture is put in place. let rosterEntries: Roster[]; - if (unit) { + if (unitId) { rosterEntries = await Roster.find({ - where: { - org, - unit, + relations: ['unit', 'unit.org'], + where: (qb: any) => { + qb.where('unit_id = :unitId', { unitId }) + .andWhere('unit_org = :orgId', { orgId: org.id }); }, }); } else { rosterEntries = await Roster.find({ - where: { - org, + relations: ['unit', 'unit.org'], + where: (qb: any) => { + qb.where('unit_org = :orgId', { orgId: org.id }); }, }); } @@ -347,34 +328,23 @@ async function getIndividualsData(org: Org, role: Role, intervalCount: number, u maxReportsByEdipi[entry.edipi] = endDate.diff(startDate, 'days'); } - // Aggregate all existing reports within this time interval. - const filter = [{ - range: { - Timestamp: { - gte: timeRange.startDate.format(dateFormat), - lt: timeRange.endDate.format(dateFormat), - }, - }, - }] as any[]; - - if (unit) { - filter.push({ - term: { - 'Roster.unit.keyword': unit, - }, - }); - } - // Send request. let response: SearchResponse; try { response = await elasticsearch.search({ - index: role.getKibanaIndexForMuster(), + index: role.getKibanaIndexForMuster(unitId), body: { size: 0, query: { bool: { - filter, + filter: [{ + range: { + Timestamp: { + gte: timeRange.startDate.format(dateFormat), + lt: timeRange.endDate.format(dateFormat), + }, + }, + }], }, }, aggs: { @@ -391,7 +361,7 @@ async function getIndividualsData(org: Org, role: Role, intervalCount: number, u throw new InternalServerError(`Elasticsearch: ${err.message}`); } - const reportsByPersonBuckets = response.aggregations.reportsByPerson.buckets as { + const reportsByPersonBuckets = (response.aggregations ? response.aggregations.reportsByPerson.buckets : []) as { key: string doc_count: number }[]; @@ -420,7 +390,7 @@ async function getIndividualsData(org: Org, role: Role, intervalCount: number, u return diff; } - diff = a.unit.localeCompare(b.unit); + diff = a.unit.name.localeCompare(b.unit.name); if (diff !== 0) { return diff; } @@ -441,6 +411,7 @@ async function getIndividualsData(org: Org, role: Role, intervalCount: number, u Reflect.set(individualCleaned, columnInfo.name, columnValue); } individualCleaned.id = individual.id; + individualCleaned.unitId = individual.unit.id; individualCleaned.nonMusterPercent = individual.nonMusterPercent; return individualCleaned; }); @@ -461,26 +432,26 @@ async function getUnitRosterCounts(interval: 'week' | 'month', intervalCount: nu const startDate = moment.utc().subtract(i + 1, interval).startOf(momentUnitOfTime).format(dateFormat); const endDate = moment.utc(startDate).add(1, interval).format(dateFormat); queries.push(` - SELECT unit, count(id), '${startDate}' as date + SELECT unit_id as "unitId", count(id), '${startDate}' as date FROM roster WHERE (start_date IS null AND (end_date IS null OR end_date > '${endDate}')) OR (start_date <= '${startDate}' AND (end_date IS null OR end_date > '${endDate}')) - GROUP BY unit + GROUP BY "unitId" `); dates.add(startDate); } const rows = await getConnection().query(queries.join(`UNION`)) as { - unit: string + unitId: string date: string count: string }[]; const unitRosterCountByDate = {} as { [date: string]: { - [unitName: string]: number + [unitId: string]: number } }; @@ -493,13 +464,13 @@ async function getUnitRosterCounts(interval: 'week' | 'month', intervalCount: nu for (const row of rows) { const date = row.date; - const unit = row.unit; + const unitId = row.unitId; - if (!unitRosterCountByDate[date][unit]) { - unitRosterCountByDate[date][unit] = 0; + if (!unitRosterCountByDate[date][unitId]) { + unitRosterCountByDate[date][unitId] = 0; } - unitRosterCountByDate[date][unit] += parseInt(row.count); + unitRosterCountByDate[date][unitId] += parseInt(row.count); } return unitRosterCountByDate; @@ -516,7 +487,7 @@ function calcNonMusterPercent(reports: number, maxReports: number) { type GetIndividualsQuery = { intervalCount: string - unit: string + unitId: string } & PagedQuery; type GetTrendsQuery = { @@ -526,6 +497,7 @@ type GetTrendsQuery = { type MusterIndividual = { nonMusterPercent: number + unitId: string } & Roster; export default new MusterController(); diff --git a/server/api/role/role.model.ts b/server/api/role/role.model.ts index 6d6e43ca..83eb1087 100644 --- a/server/api/role/role.model.ts +++ b/server/api/role/role.model.ts @@ -110,8 +110,8 @@ export class Role extends BaseEntity { return `${this.org!.indexPrefix}-${this.getUnitFilter()}-${suffix}-*`; } - getKibanaIndexForMuster() { - return `${this.org!.indexPrefix}-${this.getUnitFilter()}-phi-*`; + getKibanaIndexForMuster(unitId?: string) { + return `${this.org!.indexPrefix}-${unitId || this.getUnitFilter()}-phi-*`; } getKibanaRoles() { diff --git a/server/api/roster/index.ts b/server/api/roster/index.ts index bc05b828..68fe3535 100644 --- a/server/api/roster/index.ts +++ b/server/api/roster/index.ts @@ -99,13 +99,6 @@ router.post( controller.uploadRosterEntries, ); -router.get( - '/:orgId/units', - requireOrgAccess, - requireRolePermission(role => role.canViewRoster), - controller.getUnits, -); - router.get( '/:orgId/:rosterId', requireOrgAccess, diff --git a/server/api/roster/roster.controller.ts b/server/api/roster/roster.controller.ts index 5d25073e..9e8cd673 100644 --- a/server/api/roster/roster.controller.ts +++ b/server/api/roster/roster.controller.ts @@ -15,6 +15,7 @@ import { BaseType, getOptionalParam, getRequiredParam } from '../../util/util'; import { Org } from '../org/org.model'; import { CustomRosterColumn } from './custom-roster-column.model'; import { Role } from '../role/role.model'; +import { Unit } from '../unit/unit.model'; class RosterController { @@ -37,7 +38,9 @@ class RosterController { }, }); - if (baseRosterColumns.find(column => column.name === columnName) || existingColumn) { + if (existingColumn + || columnName.toLowerCase() === 'unit' + || baseRosterColumns.find(column => column.name.toLowerCase() === columnName.toLowerCase())) { throw new BadRequestError('There is already a column with that name.'); } @@ -121,8 +124,8 @@ class RosterController { async getRosterTemplate(req: ApiRequest, res: Response) { const columns = await getAllowedRosterColumns(req.appOrg!, req.appRole!); - const headers: string[] = []; - const example: string[] = []; + const headers: string[] = ['unit']; + const example: string[] = ['unit1']; columns.forEach(column => { headers.push(column.name); if (column.name === 'edipi') { @@ -194,10 +197,24 @@ class RosterController { fs.unlinkSync(req.file.path); } - const columns = await getAllowedRosterColumns(req.appOrg!, req.appRole!); + const orgUnits = await Unit.find({ + relations: ['org'], + where: { + org: org.id, + }, + }); + + const columns = await getAllowedRosterColumns(org, req.appRole!); roster.forEach(row => { + if (!row.unit) { + throw new BadRequestError('Unable to add roster entries without a unit ID.'); + } + const unit = orgUnits.find(u => row.unit === u.id); + if (!unit) { + throw new NotFoundError(`Unit with ID ${row.unit} could not be found in the group.`); + } const entry = new Roster(); - entry.org = org; + entry.unit = unit; for (const column of columns) { getColumnFromCSV(entry, row, column); } @@ -210,22 +227,6 @@ class RosterController { }); } - async getUnits(req: ApiRequest, res: Response) { - const rows = await Roster.createQueryBuilder() - .select(['unit']) - .where({ - org: req.appOrg, - }) - .distinct() - .getRawMany<{ unit: string }>(); - - const units = rows - .map(row => row.unit) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - - res.json(units); - } - async getFullRosterInfo(req: ApiRequest, res: Response) { const columns = await getRosterColumns(req.appOrg!.id); res.json(columns); @@ -242,7 +243,7 @@ class RosterController { throw new BadRequestError('Missing reportDate.'); } const entries = (await Roster.find({ - relations: ['org'], + relations: ['unit', 'unit.org'], where: { edipi: req.params.edipi, }, @@ -250,13 +251,14 @@ class RosterController { const responseData: RosterInfo[] = []; for (const roster of entries) { - const columns = (await getRosterColumns(roster.org!.id)).map(column => ({ + const columns = (await getRosterColumns(roster.unit.org!.id)).map(column => ({ ...column, value: roster.getColumnValue(column), } as RosterColumnWithValue)); const rosterInfo: RosterInfo = { - org: roster.org!, + unit: roster.unit, + id: roster.id, columns, }; responseData.push(rosterInfo); @@ -270,9 +272,10 @@ class RosterController { async addRosterEntry(req: ApiRequest, res: Response) { const edipi = req.body.edipi as string; const rosterEntries = await Roster.find({ - where: { - edipi, - org: req.appOrg!.id, + relations: ['unit'], + where: (qb: any) => { + qb.where({ edipi }) + .andWhere('unit_org = :orgId', { orgId: req.appOrg!.id }); }, }); @@ -281,11 +284,25 @@ class RosterController { if (rosterEntries.some(roster => isActiveOnRoster(roster, now))) { throw new BadRequestError('The individual is already in the roster.'); } + if (!req.body.unit) { + throw new BadRequestError('A unit must be supplied when adding a roster entry.'); + } + + const unit = await Unit.findOne({ + relations: ['org'], + where: { + org: req.appOrg?.id, + id: req.body.unit, + }, + }); + if (!unit) { + throw new NotFoundError(`Unit with ID ${req.body.unit} could not be found.`); + } const entry = new Roster(); - entry.org = req.appOrg; + entry.unit = unit; const columns = await getAllowedRosterColumns(req.appOrg!, req.appRole!); - setRosterParamsFromBody(entry, req.body, columns, true); + await setRosterParamsFromBody(req.appOrg!, entry, req.body, columns, true); const newRosterEntry = await entry.save(); res.status(201).json(newRosterEntry); @@ -296,7 +313,7 @@ class RosterController { const queryBuilder = await queryAllowedRoster(req.appOrg!, req.appRole!); const rosterEntry = await queryBuilder - .andWhere('id=\':id\'', { + .andWhere('roster.id=\':id\'', { id: rosterId, }) .getRawOne(); @@ -312,7 +329,7 @@ class RosterController { const rosterId = req.params.rosterId; const rosterEntry = await Roster.findOne({ - relations: ['org'], + relations: ['unit'], where: { id: rosterId, }, @@ -331,7 +348,7 @@ class RosterController { const rosterId = req.params.rosterId; const entry = await Roster.findOne({ - relations: ['org'], + relations: ['unit'], where: { id: rosterId, }, @@ -340,8 +357,22 @@ class RosterController { if (!entry) { throw new NotFoundError('User could not be found.'); } + + if (req.body.unit) { + const unit = await Unit.findOne({ + relations: ['org'], + where: { + org: req.appOrg?.id, + id: req.body.unit, + }, + }); + if (!unit) { + throw new NotFoundError(`Unit with ID ${req.body.unit} could not be found.`); + } + entry.unit = unit; + } const columns = await getAllowedRosterColumns(req.appOrg!, req.appRole!); - setRosterParamsFromBody(entry, req.body, columns); + await setRosterParamsFromBody(req.appOrg!, entry, req.body, columns); const updatedRosterEntry = await entry.save(); res.json(updatedRosterEntry); @@ -354,9 +385,11 @@ class RosterController { */ async function queryAllowedRoster(org: Org, role: Role) { const columns = await getAllowedRosterColumns(org, role); - const queryBuilder = Roster.createQueryBuilder().select([]); + const queryBuilder = Roster.createQueryBuilder('roster').select([]); + queryBuilder.leftJoin('roster.unit', 'u'); // Always select the id column - queryBuilder.addSelect('id'); + queryBuilder.addSelect('roster.id', 'id'); + queryBuilder.addSelect('u.id', 'unit'); // Add all columns that are allowed by the user's role columns.forEach(column => { @@ -365,29 +398,27 @@ async function queryAllowedRoster(org: Org, role: Role) { let selection: string; switch (column.type) { case RosterColumnType.Boolean: - selection = `(custom_columns ->> '${column.name}')::BOOLEAN`; + selection = `(roster.custom_columns ->> '${column.name}')::BOOLEAN`; break; case RosterColumnType.Number: - selection = `(custom_columns ->> '${column.name}')::DOUBLE PRECISION`; + selection = `(roster.custom_columns ->> '${column.name}')::DOUBLE PRECISION`; break; default: - selection = `custom_columns ->> '${column.name}'`; + selection = `roster.custom_columns ->> '${column.name}'`; break; } queryBuilder.addSelect(selection, column.name); } else { - queryBuilder.addSelect(snakeCase(column.name), column.name); + queryBuilder.addSelect(`roster.${snakeCase(column.name)}`, column.name); } }); // Filter out roster entries that are not on the active roster or are not allowed by the role's index prefix. return queryBuilder - .where({ - org: org.id, - }) - .andWhere('(end_date IS NULL OR end_date > now())') - .andWhere('(start_date IS NULL OR start_date < now())') - .andWhere('unit like :name', { name: role.indexPrefix.replace('*', '%') }); + .where('u.org_id = :orgId', { orgId: org.id }) + .andWhere('(roster.end_date IS NULL OR roster.end_date > now())') + .andWhere('(roster.start_date IS NULL OR roster.start_date < now())') + .andWhere('u.id like :name', { name: role.indexPrefix.replace('*', '%') }); } export async function getAllowedRosterColumns(org: Org, role: Role) { @@ -449,15 +480,15 @@ function isActiveOnRoster(entry: Roster, date: Date) { && (!entry.endDate || entry.endDate.getTime() >= date.getTime()); } -function setRosterParamsFromBody(entry: Roster, body: RosterEntryData, columns: RosterColumnInfo[], newEntry: boolean = false) { - columns.forEach(column => { +async function setRosterParamsFromBody(org: Org, entry: Roster, body: RosterEntryData, columns: RosterColumnInfo[], newEntry: boolean = false) { + for (const column of columns) { if (newEntry || column.updatable) { - getColumnFromBody(entry, body, column, newEntry); + await getColumnFromBody(org, entry, body, column, newEntry); } - }); + } } -function getColumnFromBody(roster: Roster, row: RosterEntryData, column: RosterColumnInfo, newEntry: boolean) { +async function getColumnFromBody(org: Org, roster: Roster, row: RosterEntryData, column: RosterColumnInfo, newEntry: boolean) { let objectValue: Date | CustomColumnValue | undefined; let paramType: BaseType; switch (column.type) { @@ -556,7 +587,8 @@ interface RosterColumnWithValue extends RosterColumnInfo { } interface RosterInfo { - org: Org, + unit: Unit, + id: number, columns: RosterColumnInfo[], } diff --git a/server/api/roster/roster.model.ts b/server/api/roster/roster.model.ts index ce356b07..c83d8bdb 100644 --- a/server/api/roster/roster.model.ts +++ b/server/api/roster/roster.model.ts @@ -1,7 +1,7 @@ import { - Entity, Column, BaseEntity, JoinColumn, ManyToOne, CreateDateColumn, PrimaryGeneratedColumn, + Entity, Column, BaseEntity, ManyToOne, CreateDateColumn, PrimaryGeneratedColumn, } from 'typeorm'; -import { Org } from '../org/org.model'; +import { Unit } from '../unit/unit.model'; @Entity() export class Roster extends BaseEntity { @@ -9,15 +9,11 @@ export class Roster extends BaseEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => Org, org => org.id, { + @ManyToOne(() => Unit, unit => unit.id, { nullable: false, - cascade: true, - onDelete: 'CASCADE', + onDelete: 'RESTRICT', }) - @JoinColumn({ - name: 'org_id', - }) - org?: Org; + unit!: Unit; @Column({ length: 10, @@ -34,11 +30,6 @@ export class Roster extends BaseEntity { }) lastName!: string; - @Column({ - length: 50, - }) - unit!: string; - @Column({ nullable: true, default: () => 'null', @@ -130,15 +121,6 @@ export const baseRosterColumns: RosterColumnInfo[] = [ custom: false, required: true, updatable: true, - }, { - name: 'unit', - displayName: 'Unit', - type: RosterColumnType.String, - pii: false, - phi: false, - custom: false, - required: true, - updatable: true, }, { name: 'startDate', displayName: 'Start Date', diff --git a/server/api/unit/index.ts b/server/api/unit/index.ts new file mode 100644 index 00000000..2e4ae9e2 --- /dev/null +++ b/server/api/unit/index.ts @@ -0,0 +1,38 @@ +import express from 'express'; +import bodyParser from 'body-parser'; +import controller from './unit.controller'; +import { requireOrgAccess, requireRolePermission } from '../../auth'; + + +const router = express.Router() as any; + +router.get( + '/:orgId', + requireOrgAccess, + controller.getUnits, +); + +router.post( + '/:orgId', + requireOrgAccess, + requireRolePermission(role => role.canManageGroup), + bodyParser.json(), + controller.addUnit, +); + +router.put( + '/:orgId/:unitId', + requireOrgAccess, + requireRolePermission(role => role.canManageGroup), + bodyParser.json(), + controller.updateUnit, +); + +router.delete( + '/:orgId/:unitId', + requireOrgAccess, + requireRolePermission(role => role.canManageGroup), + controller.deleteUnit, +); + +export default router; diff --git a/server/api/unit/unit.controller.ts b/server/api/unit/unit.controller.ts new file mode 100644 index 00000000..0b520bd1 --- /dev/null +++ b/server/api/unit/unit.controller.ts @@ -0,0 +1,145 @@ +import { Response } from 'express'; +import { + ApiRequest, OrgParam, OrgUnitParams, +} from '../index'; +import { MusterConfiguration, Unit } from './unit.model'; +import { + BadRequestError, NotFoundError, +} from '../../util/error-types'; +import { Roster } from '../roster/roster.model'; +import { matchWildcardString, sanitizeIndexPrefix } from '../../util/util'; + +class UnitController { + + async getUnits(req: ApiRequest, res: Response) { + if (!req.appRole?.indexPrefix) { + res.json([]); + return; + } + const units = await Unit + .createQueryBuilder('unit') + .leftJoinAndSelect('unit.org', 'org') + .where('unit.org_id=:orgId', { orgId: req.appOrg!.id }) + .andWhere('unit.id like :unitFilter', { + unitFilter: req.appRole!.indexPrefix.replace('*', '%'), + }) + .orderBy('unit.id', 'ASC') + .getMany(); + + res.json(units); + } + + async addUnit(req: ApiRequest, res: Response) { + if (!req.body.id) { + throw new BadRequestError('An ID must be supplied when adding a unit.'); + } + if (!matchWildcardString(req.body.id, req.appRole!.indexPrefix)) { + throw new BadRequestError('The provided unit ID does not conform to the unit filter for your role.'); + } + if (!req.body.name) { + throw new BadRequestError('A name must be supplied when adding a unit.'); + } + + if (req.body.id !== sanitizeIndexPrefix(req.body.id)) { + throw new BadRequestError('Only lowercase letters, numbers, and underscores are allowed in the unit ID.'); + } + + const existingUnit = await Unit.findOne({ + where: { + id: req.body.id, + org: req.appOrg!.id, + }, + }); + + if (existingUnit) { + throw new BadRequestError('There is already a unit with that ID.'); + } + + const unit = new Unit(); + unit.org = req.appOrg; + setUnitFromBody(unit, req.body); + + const newUnit = await unit.save(); + res.status(201).json(newUnit); + } + + async updateUnit(req: ApiRequest, res: Response) { + if (!matchWildcardString(req.params.unitId, req.appRole!.indexPrefix)) { + // If they don't have permission to see the unit, treat it as not found + throw new NotFoundError('The unit could not be found.'); + } + const existingUnit = await Unit.findOne({ + relations: ['org'], + where: { + id: req.params.unitId, + org: req.appOrg!.id, + }, + }); + + if (!existingUnit) { + throw new NotFoundError('The unit could not be found.'); + } + + if (req.body.id && req.body.id !== existingUnit.id) { + throw new BadRequestError('Unable to change the ID of a unit.'); + } + + setUnitFromBody(existingUnit, req.body); + const updatedUnit = await existingUnit.save(); + + res.json(updatedUnit); + } + + async deleteUnit(req: ApiRequest, res: Response) { + if (!matchWildcardString(req.params.unitId, req.appRole!.indexPrefix)) { + // If they don't have permission to see the unit, treat it as not found + throw new NotFoundError('The unit could not be found.'); + } + const existingUnit = await Unit.findOne({ + relations: ['org'], + where: { + id: req.params.unitId, + org: req.appOrg!.id, + }, + }); + + if (!existingUnit) { + throw new NotFoundError('The unit could not be found.'); + } + + const unitCount = await Roster.count({ + where: { + unit: existingUnit.id, + }, + }); + + if (unitCount > 0) { + throw new BadRequestError('Cannot delete a unit that has individuals assigned to it.'); + } + + const deletedUnit = await existingUnit.remove(); + res.json(deletedUnit); + } +} + + +function setUnitFromBody(unit: Unit, body: UnitData) { + if (body.name) { + unit.name = body.name; + } + if (body.id) { + unit.id = body.id; + } + if (body.musterConfiguration) { + unit.musterConfiguration = body.musterConfiguration; + } +} + + +export interface UnitData { + id?: string, + name?: string, + musterConfiguration?: MusterConfiguration[], +} + +export default new UnitController(); diff --git a/server/api/unit/unit.model.ts b/server/api/unit/unit.model.ts new file mode 100644 index 00000000..797cb9a7 --- /dev/null +++ b/server/api/unit/unit.model.ts @@ -0,0 +1,37 @@ +import { + Entity, Column, BaseEntity, JoinColumn, ManyToOne, PrimaryColumn, +} from 'typeorm'; +import { Org } from '../org/org.model'; + +@Entity() +export class Unit extends BaseEntity { + + @PrimaryColumn() + id!: string; + + @ManyToOne(() => Org, org => org.id, { + nullable: false, + primary: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'org_id', + }) + org?: Org; + + @Column() + name!: string; + + @Column('json', { + nullable: false, + default: '[]', + }) + musterConfiguration: MusterConfiguration[] = []; +} + +export interface MusterConfiguration { + days: number, + startTime: string, + timezone: string, + durationMinutes: number, +} diff --git a/server/api/user/user.controller.ts b/server/api/user/user.controller.ts index 28c744da..75c97256 100644 --- a/server/api/user/user.controller.ts +++ b/server/api/user/user.controller.ts @@ -113,7 +113,7 @@ class UserController { const orgRoleIndex = user.roles.findIndex(userRole => userRole.org!.id === org.id); if (orgRoleIndex >= 0) { - user.roles.splice(orgRoleIndex, 1) + user.roles.splice(orgRoleIndex, 1); } user.roles.push(role); @@ -167,7 +167,7 @@ class UserController { const userEDIPI = req.params.edipi; if (req.appUser.edipi === userEDIPI) { - throw new Error("Unable to remove yourself from the group.") + throw new Error('Unable to remove yourself from the group.'); } const user = await User.findOne({ @@ -177,13 +177,13 @@ class UserController { }, }); - const roleIndex = (user?.roles ?? []).findIndex((userRole) => userRole.org!.id === req.appOrg!.id); + const roleIndex = (user?.roles ?? []).findIndex(userRole => userRole.org!.id === req.appOrg!.id); if (roleIndex !== -1) { user!.roles!.splice(roleIndex, 1); - res.json(await user!.save()) + res.json(await user!.save()); } else { - res.json({}) + res.json({}); } } diff --git a/server/migration/1606230125211-Units.ts b/server/migration/1606230125211-Units.ts new file mode 100644 index 00000000..25cdc24c --- /dev/null +++ b/server/migration/1606230125211-Units.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { Unit } from '../api/unit/unit.model'; +import { Org } from '../api/org/org.model'; +import { sanitizeIndexPrefix } from '../util/util'; + +export class Units1606230125211 implements MigrationInterface { + name = 'Units1606230125211' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "unit" ("id" character varying NOT NULL, "name" character varying NOT NULL, "muster_configuration" json NOT NULL DEFAULT '[]', "org_id" integer NOT NULL, CONSTRAINT "PK_a01b525274c7f20afb31a742d47" PRIMARY KEY ("id", "org_id"))`); + + await queryRunner.query(`ALTER TABLE "roster" ADD "unit_id" character varying`); + await queryRunner.query(`ALTER TABLE "roster" ADD "unit_org" integer`); + const orgs = await queryRunner.manager.getRepository(Org).find(); + const unitRepo = queryRunner.manager.getRepository(Unit); + for (const org of orgs) { + const orgUnits = (await queryRunner.query(`SELECT DISTINCT "unit" FROM "roster" WHERE "org_id"=${org.id}`)).map((roster: { unit: string }) => roster.unit); + + for (const unitId of orgUnits) { + const unit = new Unit(); + unit.id = sanitizeIndexPrefix(unitId); + unit.name = unitId; + unit.org = org; + unit.musterConfiguration = []; + await unitRepo.save(unit); + + await queryRunner.query(`UPDATE "roster" SET "unit_id"='${unit.id}' WHERE "unit"='${unit.name}' AND "org_id"=${org.id}`); + await queryRunner.query(`UPDATE "roster" SET "unit_org"=${org.id} WHERE "unit"='${unit.name}' AND "org_id"=${org.id}`); + } + } + + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN "unit_id" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN "unit_org" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "roster" DROP CONSTRAINT "FK_933f7dbcd30d5bc6eb9e2048510"`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "unit"`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "org_id"`); + await queryRunner.query(`ALTER TABLE "unit" ADD CONSTRAINT "FK_c6c0d1d31080b7f603d960238f1" FOREIGN KEY ("org_id") REFERENCES "org"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "roster" ADD CONSTRAINT "FK_b12544f980cb8f403bc514a2ab5" FOREIGN KEY ("unit_id", "unit_org") REFERENCES "unit"("id","org_id") ON DELETE RESTRICT ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roster" ADD "unit" character varying(50)`); + await queryRunner.query(`ALTER TABLE "roster" ADD "org_id" integer`); + await queryRunner.query(`UPDATE "roster" SET "unit"="unit"."id" FROM "unit" WHERE "unit"."id"="roster"."unit_id"`); + await queryRunner.query(`UPDATE "roster" SET "org_id"="unit"."org_id" FROM "unit" WHERE "unit"."id"="roster"."unit_id"`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN "unit" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN "org_id" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "roster" DROP CONSTRAINT "FK_b12544f980cb8f403bc514a2ab5"`); + await queryRunner.query(`ALTER TABLE "unit" DROP CONSTRAINT "FK_c6c0d1d31080b7f603d960238f1"`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "unit_org"`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "unit_id"`); + await queryRunner.query(`DROP TABLE "unit"`); + await queryRunner.query(`ALTER TABLE "roster" ADD CONSTRAINT "FK_933f7dbcd30d5bc6eb9e2048510" FOREIGN KEY ("org_id") REFERENCES "org"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/server/ormconfig.ts b/server/ormconfig.ts index cb7b6c71..f71e3c66 100644 --- a/server/ormconfig.ts +++ b/server/ormconfig.ts @@ -5,6 +5,7 @@ import { Org } from './api/org/org.model'; import { Role } from './api/role/role.model'; import { Roster } from './api/roster/roster.model'; import { User } from './api/user/user.model'; +import { Unit } from './api/unit/unit.model'; import { Workspace } from './api/workspace/workspace.model'; import { WorkspaceTemplate } from './api/workspace/workspace-template.model'; import { CustomRosterColumn } from './api/roster/custom-roster-column.model'; @@ -24,6 +25,7 @@ export const ormConfig: PostgresConnectionOptions = { Org, Roster, AccessRequest, + Unit, Workspace, WorkspaceTemplate, CustomRosterColumn, diff --git a/server/sqldb/seed-dev.ts b/server/sqldb/seed-dev.ts index 66977bba..2ea77762 100644 --- a/server/sqldb/seed-dev.ts +++ b/server/sqldb/seed-dev.ts @@ -6,6 +6,7 @@ import { User } from '../api/user/user.model'; import { Workspace } from '../api/workspace/workspace.model'; import { Roster, RosterColumnType } from '../api/roster/roster.model'; import { CustomRosterColumn } from '../api/roster/custom-roster-column.model'; +import { Unit } from '../api/unit/unit.model'; export default (async function() { if (process.env.NODE_ENV !== 'development') { @@ -18,7 +19,7 @@ export default (async function() { // Create users const groupAdmin = new User(); - groupAdmin.edipi = '1'; + groupAdmin.edipi = '0000000001'; groupAdmin.firstName = 'Group'; groupAdmin.lastName = 'Admin'; groupAdmin.phone = '123-456-7890'; @@ -77,14 +78,23 @@ async function generateOrg(orgNum: number, admin: User, numUsers: number, numRos await user.save(); } + const units: Unit[] = []; + for (let i = 1; i <= 5; i++) { + const unit = new Unit(); + unit.org = org; + unit.name = `Unit ${i}`; + unit.id = `unit${i}`; + unit.musterConfiguration = []; + units.push(await unit.save()); + } + for (let i = 0; i < numRosterEntries; i++) { const rosterEntry = new Roster(); - rosterEntry.org = org; rosterEntry.edipi = `${orgNum}${`${i}`.padStart(9, '0')}`; rosterEntry.firstName = 'Roster'; rosterEntry.lastName = `Entry${i}`; // Ensure at least some roster entries are in unit 1. - rosterEntry.unit = (i % 2 === 0) ? 'unit1' : `unit${randomNumber(2, 5)}`; + rosterEntry.unit = (i % 2 === 0) ? units[0] : units[randomNumber(1, 4)]; rosterEntry.lastReported = new Date(); const customColumns: any = {}; customColumns[customColumn.name] = `custom column value`; diff --git a/server/util/util.ts b/server/util/util.ts index 264caffe..ae1db21d 100644 --- a/server/util/util.ts +++ b/server/util/util.ts @@ -24,6 +24,15 @@ export function escapeRegExp(value: string) { return value.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } +export function sanitizeIndexPrefix(prefix: string) { + return prefix.replace(/\s*$/g, '').replace(/[^a-z0-9_]/gi, '_').toLowerCase(); +} + +export function matchWildcardString(str: string, pattern: string) { + const escapeRegex = (part: string) => part.replace(/([.*+?^=!:${}()|[]\/\\])/g, '\\$1'); + return new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`).test(str); +} + export type BaseType = ( 'string' | 'number' | diff --git a/src/actions/unit.actions.ts b/src/actions/unit.actions.ts new file mode 100644 index 00000000..45799714 --- /dev/null +++ b/src/actions/unit.actions.ts @@ -0,0 +1,39 @@ +import { Dispatch } from 'redux'; +import { UnitClient } from '../client'; +import { ApiUnit } from '../models/api-response'; + +export namespace Unit { + + export namespace Actions { + + export class Fetch { + static type = 'FETCH_UNITS'; + type = Fetch.type; + } + export class FetchSuccess { + static type = `${Fetch.type}_SUCCESS`; + type = FetchSuccess.type; + constructor(public payload: { + units: ApiUnit[] + }) {} + } + export class FetchFailure { + static type = `${Fetch.type}_FAILURE`; + type = FetchFailure.type; + constructor(public payload: { + error: any + }) { } + } + } + + export const fetch = (orgId: number) => async (dispatch: Dispatch) => { + dispatch(new Actions.Fetch()); + try { + const units = await UnitClient.fetchAll(orgId); + dispatch(new Actions.FetchSuccess({ units })); + } catch (error) { + dispatch(new Actions.FetchFailure({ error })); + } + }; +} + diff --git a/src/client/index.ts b/src/client/index.ts index 16a3892e..7252ce7d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'; import axiosRetry from 'axios-retry'; import { UserRegisterData } from '../actions/user.actions'; import { - ApiAccessRequest, ApiNotification, ApiRole, ApiRosterColumnInfo, ApiUser, ApiWorkspace, + ApiAccessRequest, ApiNotification, ApiRole, ApiRosterColumnInfo, ApiUnit, ApiUser, ApiWorkspace, } from '../models/api-response'; const client = axios.create({ @@ -52,6 +52,12 @@ export namespace RoleClient { }; } +export namespace UnitClient { + export const fetchAll = (orgId: number): Promise => { + return client.get(`unit/${orgId}`); + }; +} + export namespace RosterClient { export const fetchColumns = (orgId: number): Promise => { return client.get(`roster/${orgId}/column`); @@ -59,7 +65,7 @@ export namespace RosterClient { export const upload = (orgId: number, file: File): Promise => { const formData = new FormData(); formData.append('roster_csv', file); - return client.post(`api/roster/${orgId}/bulk`, formData, { + return client.post(`roster/${orgId}/bulk`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, @@ -83,7 +89,7 @@ export namespace UserClient { export namespace WorkspaceClient { export const fetchAll = (orgId: number): Promise => { - return client.get(`workspaces/${orgId}`); + return client.get(`workspace/${orgId}`); }; } diff --git a/src/components/app-sidenav/app-sidenav.tsx b/src/components/app-sidenav/app-sidenav.tsx index 783576bf..3b62de69 100644 --- a/src/components/app-sidenav/app-sidenav.tsx +++ b/src/components/app-sidenav/app-sidenav.tsx @@ -6,6 +6,7 @@ import BarChartIcon from '@material-ui/icons/BarChart'; import HomeIcon from '@material-ui/icons/Home'; import ListAltIcon from '@material-ui/icons/ListAlt'; import PeopleIcon from '@material-ui/icons/People'; +import GroupWorkIcon from '@material-ui/icons/GroupWork'; import SecurityIcon from '@material-ui/icons/Security'; import AssessmentOutlinedIcon from '@material-ui/icons/AssessmentOutlined'; import clsx from 'clsx'; @@ -91,6 +92,15 @@ export const AppSidenav = () => { )} + {user.activeRole?.canManageGroup && ( + + + + + + + )} + {user.activeRole?.canManageRoster && ( diff --git a/src/components/app.tsx b/src/components/app.tsx index 00aa4e2b..527a866e 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -22,6 +22,7 @@ import { RoleManagementPage } from './pages/role-management-page/role-management import { WorkspacesPage } from './pages/workspaces-page/workspaces-page'; import { RosterColumnsPage } from './pages/roster-columns-page/roster-columns-page'; import { SettingsPage } from './pages/settings-page/settings-page'; +import { UnitsPage } from './pages/units-page/units-page'; export const App = () => { const user = useSelector(state => state.user); @@ -85,6 +86,9 @@ export const App = () => { + + + diff --git a/src/components/pages/muster-page/muster-page.tsx b/src/components/pages/muster-page/muster-page.tsx index 2dbc32b4..7de93ad6 100644 --- a/src/components/pages/muster-page/muster-page.tsx +++ b/src/components/pages/muster-page/muster-page.tsx @@ -1,6 +1,17 @@ import { - Container, Paper, Table, TableContainer, TableRow, TableFooter, Select, MenuItem, Grid, Card, CardContent, Typography, Box, + Card, + CardContent, + Container, + Grid, + MenuItem, + Paper, + Select, + Table, + TableContainer, + TableFooter, + TableRow, + Typography, } from '@material-ui/core'; import GetAppIcon from '@material-ui/icons/GetApp'; import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; @@ -25,10 +36,16 @@ import useStyles from './muster-page.styles'; import { UserState } from '../../../reducers/user.reducer'; import { AppState } from '../../../store'; import { - ApiRosterColumnInfo, ApiMusterTrends, ApiUnitStatsByDate, ApiRosterColumnType, ApiMusterIndividuals, ApiRosterUnits, + ApiMusterIndividuals, + ApiMusterTrends, + ApiRosterColumnInfo, + ApiRosterColumnType, + ApiUnitStatsByDate, } from '../../../models/api-response'; import { AlertDialog, AlertDialogProps } from '../../alert-dialog/alert-dialog'; import { ButtonWithSpinner } from '../../buttons/button-with-spinner'; +import { UnitSelector } from '../../../selectors/unit.selector'; +import { Unit } from '../../../actions/unit.actions'; interface TimeRange { interval: 'day' | 'hour' @@ -50,9 +67,10 @@ const stringToTimeRange = (str: string) => { export const MusterPage = () => { const classes = useStyles(); const dispatch = useDispatch(); + const units = useSelector(UnitSelector.all); const user = useSelector((state: AppState) => state.user); - const maxNumColumnsToShow = 5; + const maxNumColumnsToShow = 6; const maxTopUnitsCount = 5; const trendChart = { layout: { @@ -93,8 +111,7 @@ export const MusterPage = () => { }; const [individualsTimeRangeString, setIndividualsTimeRangeString] = useState(timeRangeToString({ interval: 'day', intervalCount: 1 })); - const [individualsUnit, setIndividualsUnit] = useState(''); - const [units, setUnits] = useState([]); + const [individualsUnitId, setIndividualsUnitId] = useState(''); const [individualsPage, setIndividualsPage] = useState(0); const [individualsRowsPerPage, setIndividualsRowsPerPage] = useState(10); const [alertDialogProps, setAlertDialogProps] = useState({ open: false }); @@ -116,9 +133,10 @@ export const MusterPage = () => { // const getUnits = useCallback(async () => { - const unitsNew = (await axios.get(`api/roster/${orgId}/units`)).data as ApiRosterUnits; - setUnits(unitsNew); - }, [orgId]); + if (orgId) { + await dispatch(Unit.fetch(orgId)); + } + }, [orgId, dispatch]); const reloadTable = useCallback(async () => { const { interval, intervalCount } = stringToTimeRange(individualsTimeRangeString); @@ -128,7 +146,7 @@ export const MusterPage = () => { params: { interval, intervalCount, - unit: individualsUnit, + unitId: individualsUnitId, page: individualsPage, limit: individualsRowsPerPage, }, @@ -153,7 +171,7 @@ export const MusterPage = () => { } else { setIndividualsData(data); } - }, [individualsTimeRangeString, individualsPage, individualsRowsPerPage, orgId, individualsUnit]); + }, [individualsTimeRangeString, individualsPage, individualsRowsPerPage, orgId, individualsUnitId]); const reloadTrendData = useCallback(async () => { try { @@ -188,37 +206,38 @@ export const MusterPage = () => { const getTrendData = useCallback((unitStatsByDate: ApiUnitStatsByDate, topUnitCount: number) => { // Sum up each unit's non-muster percent to figure out who's performing worst overall. - const nonMusterPercentSumByUnit = {} as {[unit: string]: number}; + const nonMusterPercentSumByUnit = {} as {[unitId: string]: number}; for (const date of Object.keys(unitStatsByDate)) { - for (const unit of Object.keys(unitStatsByDate[date])) { - if (nonMusterPercentSumByUnit[unit] == null) { - nonMusterPercentSumByUnit[unit] = 0; + for (const unitId of Object.keys(unitStatsByDate[date])) { + if (nonMusterPercentSumByUnit[unitId] == null) { + nonMusterPercentSumByUnit[unitId] = 0; } - nonMusterPercentSumByUnit[unit] += unitStatsByDate[date][unit].nonMusterPercent; + nonMusterPercentSumByUnit[unitId] += unitStatsByDate[date][unitId].nonMusterPercent; } } // Exclude compliant units and sort with worst performing units first. const unitsSorted = Object.keys(nonMusterPercentSumByUnit) - .filter(unit => nonMusterPercentSumByUnit[unit] > 0) - .sort(unit => nonMusterPercentSumByUnit[unit]) + .filter(unitId => nonMusterPercentSumByUnit[unitId] > 0) + .sort(unitId => nonMusterPercentSumByUnit[unitId]) .reverse() .slice(0, topUnitCount); // Build chart data. const datesSorted = Object.keys(unitStatsByDate).sort(); const trendChartData = [] as Plotly.Data[]; - for (const unitName of unitsSorted) { + for (const unitId of unitsSorted) { + const name = units.find(unit => unit.id === unitId)?.name; const chartData = { type: 'bar', x: datesSorted, y: [] as number[], - name: unitName, + name: name || unitId, hovertemplate: `%{y:.2f}%`, }; for (const date of datesSorted) { - const nonMusterPercent = unitStatsByDate[date][unitName]?.nonMusterPercent ?? 0; + const nonMusterPercent = unitStatsByDate[date][unitId]?.nonMusterPercent ?? 0; chartData.y.push(nonMusterPercent); } @@ -226,7 +245,7 @@ export const MusterPage = () => { } return trendChartData; - }, []); + }, [units]); useEffect(() => { initialize().then(); @@ -289,7 +308,7 @@ export const MusterPage = () => { params: { interval, intervalCount, - unit: individualsUnit, + unitId: individualsUnitId, }, method: 'GET', responseType: 'blob', @@ -297,8 +316,8 @@ export const MusterPage = () => { const startDate = moment().startOf('day').subtract(intervalCount, 'days').format('YYYY-MM-DD'); const endDate = moment().startOf('day').format('YYYY-MM-DD'); - const unitName = individualsUnit || 'all-units'; - const filename = `${_.kebabCase(orgName)}_${_.kebabCase(unitName)}_muster-noncompliance_${startDate}_to_${endDate}`; + const unitId = individualsUnitId || 'all-units'; + const filename = `${_.kebabCase(orgName)}_${_.kebabCase(unitId)}_muster-noncompliance_${startDate}_to_${endDate}`; downloadFile(response.data, filename, 'csv'); } catch (error) { let message = 'Internal Server Error'; @@ -319,6 +338,16 @@ export const MusterPage = () => { const getVisibleColumns = () => { const customRosterColumnInfos = [ ...rosterColumnInfos, + { + name: 'unitId', + displayName: 'Unit', + type: ApiRosterColumnType.String, + pii: false, + phi: false, + custom: false, + required: false, + updatable: false, + }, { name: 'nonMusterPercent', displayName: 'Non-Muster Rate', @@ -335,7 +364,8 @@ export const MusterPage = () => { 'edipi', 'firstName', 'lastName', - 'unit', + 'unitId', + 'lastReported', 'nonMusterPercent', ]).slice(0, maxNumColumnsToShow); }; @@ -349,7 +379,7 @@ export const MusterPage = () => { }; const handleIndividualsUnitChange = (event: ChangeEvent<{ name?: string, value: unknown }>) => { - setIndividualsUnit(event.target.value as string); + setIndividualsUnitId(event.target.value as string); }; const timeRangeToggleButton = (timeRange: TimeRange) => { @@ -392,7 +422,7 @@ export const MusterPage = () => { @@ -429,8 +459,11 @@ export const MusterPage = () => { if (column.name === 'lastReported') { return moment(row[column.name]).format('YYYY-MM-DD, HH:mm'); } - - return row[column.name]; + const value = row[column.name]; + if (column.name === 'unitId') { + return units.find(unit => unit.id === value)?.name || value; + } + return value; }, }} /> diff --git a/src/components/pages/roster-page/edit-roster-entry-dialog.style.ts b/src/components/pages/roster-page/edit-roster-entry-dialog.style.ts index fd708170..58afd7f8 100644 --- a/src/components/pages/roster-page/edit-roster-entry-dialog.style.ts +++ b/src/components/pages/roster-page/edit-roster-entry-dialog.style.ts @@ -7,6 +7,18 @@ export default makeStyles(() => createStyles({ margin: '0px -12px', }, }, + selectField: { + width: '100%', + '& .MuiFormLabel-root': { + color: '#3D4551', + fontSize: '16px', + fontWeight: '600', + }, + '& .MuiInputBase-root': { + width: '100%', + marginTop: '18px !important', + }, + }, textField: { width: '100%', margin: 0, diff --git a/src/components/pages/roster-page/edit-roster-entry-dialog.tsx b/src/components/pages/roster-page/edit-roster-entry-dialog.tsx index de7447cf..6f1d5a7a 100644 --- a/src/components/pages/roster-page/edit-roster-entry-dialog.tsx +++ b/src/components/pages/roster-page/edit-roster-entry-dialog.tsx @@ -5,8 +5,8 @@ import { Dialog, DialogActions, DialogContent, - DialogTitle, - Grid, + DialogTitle, FormControl, + Grid, InputLabel, Select, TableCell, TableRow, TextField, @@ -15,10 +15,12 @@ import { DatePicker, DateTimePicker, MuiPickersUtilsProvider } from '@material-u import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date'; import MomentUtils from '@date-io/moment'; import axios from 'axios'; +import { useSelector } from 'react-redux'; import useStyles from './edit-roster-entry-dialog.style'; import { ApiRosterColumnInfo, ApiRosterColumnType, ApiRosterEntry } from '../../../models/api-response'; import { ButtonWithSpinner } from '../../buttons/button-with-spinner'; import { EditableBooleanTable } from '../../tables/editable-boolean-table'; +import { UnitSelector } from '../../../selectors/unit.selector'; export interface EditRosterEntryDialogProps { open: boolean, @@ -31,16 +33,16 @@ export interface EditRosterEntryDialogProps { export const EditRosterEntryDialog = (props: EditRosterEntryDialogProps) => { const classes = useStyles(); - const [formDisabled, setFormDisabled] = useState(false); + const units = useSelector(UnitSelector.all); const { open, orgId, rosterColumnInfos, onClose, onError, } = props; const hiddenEditFields = ['lastReported']; - const [saveRosterEntryLoading, setSaveRosterEntryLoading] = useState(false); - const existingRosterEntry: boolean = !!props.rosterEntry; + const [formDisabled, setFormDisabled] = useState(false); + const [saveRosterEntryLoading, setSaveRosterEntryLoading] = useState(false); const [rosterEntry, setRosterEntryProperties] = useState(existingRosterEntry ? props.rosterEntry as ApiRosterEntry : {} as ApiRosterEntry); if (!open) { @@ -71,14 +73,24 @@ export const EditRosterEntryDialog = (props: EditRosterEntryDialogProps) => { updateRosterEntryProperty(columnName, date?.toISOString()); }; + const onUnitChanged = (event: React.ChangeEvent<{ value: unknown }>) => { + updateRosterEntryProperty('unit', event.target.value); + }; + const onSave = async () => { setFormDisabled(true); try { setSaveRosterEntryLoading(true); + const data = { + ...rosterEntry, + }; + if (!data.unit) { + data.unit = units[0].id; + } if (existingRosterEntry) { - await axios.put(`api/roster/${orgId}/${rosterEntry!.id}`, rosterEntry); + await axios.put(`api/roster/${orgId}/${rosterEntry!.id}`, data); } else { - await axios.post(`api/roster/${orgId}`, rosterEntry); + await axios.post(`api/roster/${orgId}`, data); } } catch (error) { if (onError) { @@ -136,22 +148,32 @@ export const EditRosterEntryDialog = (props: EditRosterEntryDialogProps) => { const columns = rosterColumnInfos?.filter(columnInfo => { return columnInfo.type === 'boolean' && hiddenEditFields.indexOf(columnInfo.name) < 0; }); - return columns?.map(columnInfo => ( - - - {columnInfo.displayName} - - - - - - )); + if (!columns || columns.length === 0) { + return <>; + } + return ( + <> + + + {columns?.map(columnInfo => ( + + + {columnInfo.displayName} + + + + + + ))} + + + ); }; const buildTextInput = (columnInfo: ApiRosterColumnInfo) => { @@ -237,12 +259,29 @@ export const EditRosterEntryDialog = (props: EditRosterEntryDialogProps) => { {existingRosterEntry ? 'Edit Roster Entry' : 'New Roster Entry'} + + + Unit + + + {buildInputFields()} - - - {buildCheckboxFields()} - + {buildCheckboxFields()} + {musterConfiguration.length > 0 && ( + + + + Days + Start Time + Time Zone + Duration (Hrs) + + + + + {musterConfiguration.map(muster => ( + + +
+ + Su + + + Mo + + + Tu + + + We + + + Th + + + Fr + + + Sa + +
+
+ + + + + + + ( + + )} + onChange={setMusterTimezone(muster.rowKey)} + /> + + + + + + removeMusterWindow(muster.rowKey)} + > + + + +
+ ))} +
+
+ )} + + {errorMessage && ( + + {errorMessage} + + )} + + + + + + + + ); + /* eslint-enable no-bitwise */ +}; diff --git a/src/components/pages/units-page/units-page.styles.ts b/src/components/pages/units-page/units-page.styles.ts new file mode 100644 index 00000000..f07723ef --- /dev/null +++ b/src/components/pages/units-page/units-page.styles.ts @@ -0,0 +1,32 @@ +import { createStyles, Theme } from '@material-ui/core'; +import { makeStyles } from '@material-ui/styles'; + +export default makeStyles((theme: Theme) => createStyles({ + root: { + flexGrow: 1, + padding: theme.spacing(3), + }, + newUnit: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + table: { + marginBottom: '39px', + }, + musterConfiguration: { + width: '50%', + }, + noMusterConfiguration: { + width: '50%', + color: '#A9AEB1', + }, + iconCell: { + width: '81px', + '& > svg': { + position: 'relative', + top: '3px', + color: theme.palette.primary.light, + }, + }, +})); diff --git a/src/components/pages/units-page/units-page.tsx b/src/components/pages/units-page/units-page.tsx new file mode 100644 index 00000000..d5c61e51 --- /dev/null +++ b/src/components/pages/units-page/units-page.tsx @@ -0,0 +1,290 @@ +import { + Button, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, + IconButton, + Menu, + MenuItem, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import moment from 'moment-timezone'; +import axios from 'axios'; +import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import useStyles from './units-page.styles'; +import { UserState } from '../../../reducers/user.reducer'; +import { AppState } from '../../../store'; +import { ApiUnit, DaysOfTheWeek, MusterConfiguration } from '../../../models/api-response'; +import { AlertDialog, AlertDialogProps } from '../../alert-dialog/alert-dialog'; +import { EditUnitDialog, EditUnitDialogProps } from './edit-unit-dialog'; +import { UnitSelector } from '../../../selectors/unit.selector'; +import { Unit } from '../../../actions/unit.actions'; +import PageHeader from '../../page-header/page-header'; + +interface UnitMenuState { + anchor: HTMLElement | null, + unit?: ApiUnit, +} + +export const UnitsPage = () => { + const classes = useStyles(); + const dispatch = useDispatch(); + const units = useSelector(UnitSelector.all); + const [unitToDelete, setUnitToDelete] = useState(null); + const [alertDialogProps, setAlertDialogProps] = useState({ open: false }); + const [editUnitDialogProps, setEditUnitDialogProps] = useState({ open: false }); + const [unitMenu, setUnitMenu] = React.useState({ anchor: null }); + + const orgId = useSelector(state => state.user).activeRole?.org?.id; + + const initializeTable = React.useCallback(async () => { + if (orgId) { + await dispatch(Unit.fetch(orgId)); + } + }, [orgId, dispatch]); + + const newUnit = async () => { + setEditUnitDialogProps({ + open: true, + orgId, + onClose: async () => { + setEditUnitDialogProps({ open: false }); + await initializeTable(); + }, + onError: (message: string) => { + setAlertDialogProps({ + open: true, + title: 'Add Unit', + message: `Unable to add unit: ${message}`, + onClose: () => { setAlertDialogProps({ open: false }); }, + }); + }, + }); + }; + + const editUnit = () => { + if (unitMenu.unit) { + setUnitMenu({ anchor: null }); + setEditUnitDialogProps({ + open: true, + unit: unitMenu.unit, + orgId, + onClose: async () => { + setEditUnitDialogProps({ open: false }); + await initializeTable(); + }, + onError: (message: string) => { + setAlertDialogProps({ + open: true, + title: 'Edit Unit', + message: `Unable to edit unit: ${message}`, + onClose: () => { setAlertDialogProps({ open: false }); }, + }); + }, + }); + } + }; + + const deleteUnit = () => { + if (unitMenu.unit) { + setUnitMenu({ anchor: null }); + setUnitToDelete(unitMenu.unit); + } + }; + + const confirmDeleteUnit = async () => { + if (!unitToDelete) { + return; + } + try { + await axios.delete(`api/unit/${orgId}/${unitToDelete.id}`); + } catch (error) { + let message = 'Internal Server Error'; + if (error.response?.data?.errors && error.response.data.errors.length > 0) { + message = error.response.data.errors[0].message; + } + setAlertDialogProps({ + open: true, + title: 'Delete Unit', + message: `Unable to delete unit: ${message}`, + onClose: () => { setAlertDialogProps({ open: false }); }, + }); + } + setUnitToDelete(null); + await initializeTable(); + }; + + const musterConfigurationToString = (muster: MusterConfiguration) => { + const days: string[] = []; + /* eslint-disable no-bitwise */ + if (muster.days & DaysOfTheWeek.Sunday) { + days.push('Sun'); + } + if (muster.days & DaysOfTheWeek.Monday) { + days.push('Mon'); + } + if (muster.days & DaysOfTheWeek.Tuesday) { + days.push('Tue'); + } + if (muster.days & DaysOfTheWeek.Wednesday) { + days.push('Wed'); + } + if (muster.days & DaysOfTheWeek.Thursday) { + days.push('Thu'); + } + if (muster.days & DaysOfTheWeek.Friday) { + days.push('Fri'); + } + if (muster.days & DaysOfTheWeek.Saturday) { + days.push('Sat'); + } + /* eslint-enable no-bitwise */ + let dayStr = days.join(', '); + if (dayStr === 'Sun, Mon, Tue, Wed, Thu, Fri, Sat') { + dayStr = 'Every day'; + } else if (dayStr === 'Mon, Tue, Wed, Thu, Fri') { + dayStr = 'Every weekday'; + } + const today = moment().format('Y-M-D'); + const time = moment.tz(`${today} ${muster.startTime}`, 'Y-M-D h:mm', muster.timezone).format('h:mm A z'); + const duration = muster.durationMinutes / 60; + return `${dayStr} at ${time} for ${duration} hours`; + }; + + const cancelDeleteUnitDialog = () => { + setUnitToDelete(null); + }; + + const handleUnitMenuClick = (unit: ApiUnit) => (event: React.MouseEvent) => { + setUnitMenu({ anchor: event.currentTarget, unit }); + }; + + const handleUnitMenuClose = () => { + setUnitMenu({ anchor: null }); + }; + + useEffect(() => { initializeTable().then(); }, [initializeTable]); + + return ( +
+ + + + + + + + + + + + + + ID + Name + Muster Requirements + + + + + {units.map(unit => ( + + {unit.id} + {unit.name} + {unit.musterConfiguration.length > 0 && ( + + {unit.musterConfiguration.map(muster => ( +
+ {musterConfigurationToString(muster)} +
+ ))} +
+ )} + {unit.musterConfiguration.length === 0 && ( + + No configured muster requirements + + )} + + + + + +
+ ))} + + Edit Unit + Delete Unit + +
+
+
+
+ {Boolean(unitToDelete) && ( + + Delete Unit + + + {`Are you sure you want to delete the '${unitToDelete?.name}' unit?`} + + + + + + + + )} + {editUnitDialogProps.open && ( + + )} + {alertDialogProps.open && ( + + )} +
+ ); +}; diff --git a/src/models/api-response.ts b/src/models/api-response.ts index 30d09fae..d3decf88 100644 --- a/src/models/api-response.ts +++ b/src/models/api-response.ts @@ -114,6 +114,7 @@ export interface ApiRosterColumnInfo extends ColumnInfo { export interface ApiRosterEntry { id: number, + unit: string, [key: string]: string | boolean | number | null, } @@ -154,4 +155,27 @@ export interface ApiMusterTrends { monthly: ApiUnitStatsByDate } -export type ApiRosterUnits = string[]; +export enum DaysOfTheWeek { + None = 0, + Sunday = 1, + Monday = 2, + Tuesday = 4, + Wednesday = 8, + Thursday = 16, + Friday = 32, + Saturday = 64, +} + +export interface MusterConfiguration { + days: DaysOfTheWeek, + startTime: string, + timezone: string, + durationMinutes: number, +} + +export interface ApiUnit { + id: string, + name: string, + org?: ApiOrg, + musterConfiguration: MusterConfiguration[], +} diff --git a/src/reducers/unit.reducer.ts b/src/reducers/unit.reducer.ts new file mode 100644 index 00000000..1dbcfec5 --- /dev/null +++ b/src/reducers/unit.reducer.ts @@ -0,0 +1,46 @@ +import { Unit } from '../actions/unit.actions'; +import { User } from '../actions/user.actions'; +import { ApiUnit } from '../models/api-response'; + +export interface UnitState { + units: ApiUnit[], + isLoading: boolean + lastUpdated: number +} + +export const unitInitialState: UnitState = { + units: [], + isLoading: false, + lastUpdated: 0, +}; + +export function unitReducer(state = unitInitialState, action: any) { + switch (action.type) { + case User.Actions.ChangeOrg.type: + return unitInitialState; + case Unit.Actions.Fetch.type: { + return { + ...state, + isLoading: true, + }; + } + case Unit.Actions.FetchSuccess.type: { + const payload = (action as Unit.Actions.FetchSuccess).payload; + return { + ...state, + units: payload.units, + isLoading: false, + lastUpdated: Date.now(), + }; + } + case Unit.Actions.FetchFailure.type: { + return { + ...state, + units: [], + isLoading: false, + }; + } + default: + return state; + } +} diff --git a/src/selectors/unit.selector.ts b/src/selectors/unit.selector.ts new file mode 100644 index 00000000..5e42ad84 --- /dev/null +++ b/src/selectors/unit.selector.ts @@ -0,0 +1,6 @@ +import { ApiUnit } from '../models/api-response'; +import { AppState } from '../store'; + +export namespace UnitSelector { + export const all = (state: AppState): ApiUnit[] => state.unit.units; +} diff --git a/src/store.ts b/src/store.ts index 21bf6a43..ccec9b69 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,11 +8,13 @@ import { RoleState, roleInitialState, roleReducer } from './reducers/role.reduce import { RosterState, rosterInitialState, rosterReducer } from './reducers/roster.reducer'; import { UserState, userInitialState, userReducer } from './reducers/user.reducer'; import { WorkspaceState, workspaceInitialState, workspaceReducer } from './reducers/workspace.reducer'; +import { unitInitialState, unitReducer, UnitState } from './reducers/unit.reducer'; export interface AppState { appFrame: AppFrameState notification: NotificationState role: RoleState + unit: UnitState roster: RosterState user: UserState workspace: WorkspaceState @@ -22,6 +24,7 @@ export const initialState: AppState = { appFrame: appFrameInitialState, notification: notificationInitialState, role: roleInitialState, + unit: unitInitialState, roster: rosterInitialState, user: userInitialState, workspace: workspaceInitialState, @@ -70,6 +73,7 @@ export default createStore( appFrame: appFrameReducer, notification: notificationReducer, role: roleReducer, + unit: unitReducer, roster: rosterReducer, user: userReducer, workspace: workspaceReducer, diff --git a/src/utility/table.ts b/src/utility/table.ts index a2a2c252..3296858a 100644 --- a/src/utility/table.ts +++ b/src/utility/table.ts @@ -5,7 +5,7 @@ export function getNewPageIndex(rowsPerPage: number, pageIndex: number, rowsPerP } export function getMaxPageIndex(rowsPerPage: number, totalRowsCount: number) { - return Math.floor(totalRowsCount / rowsPerPage); + return Math.floor(Math.max(0, totalRowsCount - 1) / rowsPerPage); } export function columnInfosOrdered(columnInfos: ColumnInfo[], columnOrder: string[]) {