├── .nvmrc ├── src ├── app │ ├── shared │ │ ├── index.ts │ │ ├── confirmation-modal │ │ │ ├── confirmation-modal.component.css │ │ │ ├── confirmation-modal.component.html │ │ │ └── confirmation-modal.component.ts │ │ ├── icon-toggle-input │ │ │ ├── icon-toggle-input.component.scss │ │ │ ├── icon-toggle-input.component.html │ │ │ └── icon-toggle-input.component.ts │ │ ├── loading-indicator │ │ │ ├── loading-indicator.component.scss │ │ │ ├── loading-indicator.component.html │ │ │ └── loading-indicator.component.ts │ │ ├── error-indicator │ │ │ ├── error-indicator.component.scss │ │ │ ├── error-indicator.component.html │ │ │ └── error-indicator.component.ts │ │ ├── pagination.interface.ts │ │ ├── base-button │ │ │ ├── base-button.component.html │ │ │ ├── base-button.component.spec.ts │ │ │ ├── base-button.component.scss │ │ │ └── base-button.component.ts │ │ ├── base-checkbox │ │ │ ├── base-checkbox.component.html │ │ │ ├── base-checkbox.component.spec.ts │ │ │ ├── base-checkbox.component.ts │ │ │ └── base-checkbox.component.scss │ │ ├── pagination.service.ts │ │ ├── base-dropdown │ │ │ ├── base-dropdown.component.spec.ts │ │ │ ├── base-dropdown.component.scss │ │ │ ├── base-dropdown.component.ts │ │ │ └── base-dropdown.component.html │ │ ├── pagination │ │ │ ├── pagination.component.scss │ │ │ └── pagination.component.ts │ │ └── shared.module.ts │ ├── app.component.scss │ ├── components │ │ ├── answer │ │ │ ├── answer.component.scss │ │ │ ├── answers-list │ │ │ │ ├── answer-list.component.scss │ │ │ │ ├── answer-list.component.html │ │ │ │ └── answer-list.component.ts │ │ │ ├── answer-details │ │ │ │ ├── answer-details.component.scss │ │ │ │ └── answer-details.component.html │ │ │ ├── answer-form-list │ │ │ │ ├── answer-form-list.component.scss │ │ │ │ ├── answer-form-list.component.html │ │ │ │ └── answer-form-list.component.ts │ │ │ ├── answer-extra-questions │ │ │ │ ├── answer-extra-questions.component.scss │ │ │ │ ├── answer-extra-questions.component.ts │ │ │ │ └── answer-extra-questions.component.html │ │ │ ├── categorical-question │ │ │ │ ├── categorical-question.component.scss │ │ │ │ └── categorical-question.component.html │ │ │ ├── answer-note │ │ │ │ ├── answer-note.component.scss │ │ │ │ ├── answer-note.component.html │ │ │ │ └── answer-note.component.ts │ │ │ └── answer.component.html │ │ ├── observers │ │ │ ├── oberver-row │ │ │ │ ├── oberver-row.component.scss │ │ │ │ └── oberver-row.component.ts │ │ │ ├── observer-card │ │ │ │ ├── observer-card.component.scss │ │ │ │ ├── observer-card.component.ts │ │ │ │ └── observer-card.component.html │ │ │ ├── observer-profile │ │ │ │ ├── observers-profile-upload.form.ts │ │ │ │ ├── observer-profile.form.ts │ │ │ │ └── observer-profile.component.scss │ │ │ ├── observers-filter.form.ts │ │ │ ├── observer-import │ │ │ │ ├── observer-import.component.scss │ │ │ │ ├── observer-import.component.spec.ts │ │ │ │ ├── observer-import.component.html │ │ │ │ └── observer-import.component.ts │ │ │ ├── base-observer-crud.component.ts │ │ │ └── observers.component.scss │ │ ├── statistics │ │ │ ├── statistics-value │ │ │ │ ├── statistics-value.component.scss │ │ │ │ ├── statistics-value.component.html │ │ │ │ └── statistics-value.component.ts │ │ │ ├── statistics.component.scss │ │ │ ├── statistics-details │ │ │ │ ├── statistics-details.component.html │ │ │ │ ├── statistics-details.component.scss │ │ │ │ └── statistics-details.component.ts │ │ │ ├── statistics.component.html │ │ │ ├── statistics-card │ │ │ │ ├── statistics-card.component.scss │ │ │ │ ├── statistics-card.component.html │ │ │ │ └── statistics-card.component.ts │ │ │ └── statistics.component.ts │ │ ├── notifications │ │ │ ├── notification-history │ │ │ │ ├── notification-history.component.scss │ │ │ │ ├── notification-history.component.html │ │ │ │ └── notification-history.component.ts │ │ │ └── notifications.component.scss │ │ ├── forms │ │ │ ├── predefined-options-modal │ │ │ │ ├── predefined-options-modal.component.scss │ │ │ │ ├── predefined-options-modal.component.html │ │ │ │ └── predefined-options-modal.component.ts │ │ │ ├── option │ │ │ │ ├── option.component.scss │ │ │ │ ├── option.component.ts │ │ │ │ └── option.component.html │ │ │ ├── section │ │ │ │ ├── section.component.scss │ │ │ │ └── section.component.ts │ │ │ ├── form-create │ │ │ │ └── form-create.component.scss │ │ │ ├── forms.component.scss │ │ │ ├── question │ │ │ │ ├── question.component.scss │ │ │ │ └── question.component.html │ │ │ └── form-groups-builder.ts │ │ ├── utils.ts │ │ ├── counties │ │ │ ├── counties.component.scss │ │ │ └── counties.component.ts │ │ ├── login │ │ │ ├── login.component.scss │ │ │ ├── login.component.html │ │ │ └── login.component.ts │ │ └── header │ │ │ ├── header.component.scss │ │ │ └── header.component.ts │ ├── index.ts │ ├── app.component.html │ ├── models │ │ ├── list.type.model.ts │ │ ├── labelValue.model.ts │ │ ├── page-state.model.ts │ │ ├── user.model.ts │ │ ├── base.answer.model.ts │ │ ├── api-list-response.model.ts │ │ ├── base.question.model.ts │ │ ├── answer.filters.model.ts │ │ ├── answer.thread.model.ts │ │ ├── form.section.model.ts │ │ ├── completed.answer.model.ts │ │ ├── form.info.model.ts │ │ ├── note.model.ts │ │ ├── completed.question.model.ts │ │ ├── notification.model.ts │ │ ├── form.question.model.ts │ │ ├── form.model.ts │ │ ├── observer.model.ts │ │ └── answer.extra.model.ts │ ├── store │ │ ├── user │ │ │ └── user.actions.ts │ │ ├── county │ │ │ ├── county.state.ts │ │ │ ├── county.selectors.ts │ │ │ ├── county.reducer.ts │ │ │ ├── county.effects.ts │ │ │ └── county.actions.ts │ │ ├── observers │ │ │ ├── observers.config.ts │ │ │ ├── observers.state.ts │ │ │ └── observers.reducer.ts │ │ ├── util.ts │ │ ├── meta-reducers │ │ │ └── index.ts │ │ ├── notifications │ │ │ ├── notifications.state.ts │ │ │ ├── notifications.reducer.ts │ │ │ ├── notifications.actions.ts │ │ │ └── notifications.effects.ts │ │ ├── statistics │ │ │ ├── statistics.state.ts │ │ │ ├── statistics.config.ts │ │ │ ├── statistics.actions.ts │ │ │ └── statistics.reducer.ts │ │ ├── form │ │ │ ├── form.selectors.ts │ │ │ └── form.reducer.ts │ │ ├── note │ │ │ ├── note.actions.ts │ │ │ ├── note.reducer.ts │ │ │ ├── note.effects.ts │ │ │ └── note.selectors.ts │ │ └── answer │ │ │ └── answer.selectors.ts │ ├── table │ │ ├── table-column │ │ │ ├── table-column.directive.ts │ │ │ └── table-column.directive.spec.ts │ │ ├── table.module.ts │ │ ├── table-container │ │ │ └── table-container.component.spec.ts │ │ └── table.model.ts │ ├── core │ │ ├── anon.guard.ts │ │ ├── core.module.ts │ │ ├── authGuard │ │ │ └── auth.guard.ts │ │ └── token │ │ │ └── token.service.ts │ ├── routing │ │ ├── guards │ │ │ ├── home.guard.ts │ │ │ ├── load-answer-list.guard.ts │ │ │ ├── load-anwer-details.guard.ts │ │ │ └── load-statistics.guard.ts │ │ └── app.routing.module.ts │ ├── answers │ │ ├── answers.model.ts │ │ ├── answer-notification │ │ │ ├── answer-notification.component.scss │ │ │ ├── answer-notification.component.spec.ts │ │ │ ├── answer-notification.component.ts │ │ │ └── answer-notification.component.html │ │ ├── notes │ │ │ ├── notes.component.spec.ts │ │ │ ├── notes.component.scss │ │ │ ├── notes.component.ts │ │ │ └── notes.component.html │ │ ├── answers │ │ │ ├── answers.component.spec.ts │ │ │ └── answers.component.scss │ │ ├── answer-details │ │ │ ├── answer-details.component.spec.ts │ │ │ └── answer-details.component.scss │ │ ├── answer-questions │ │ │ ├── answer-questions.component.spec.ts │ │ │ ├── answer-questions.component.ts │ │ │ └── answer-questions.component.scss │ │ ├── answers-routing.module.ts │ │ └── answers.module.ts │ ├── app.component.ts │ ├── app.module.ts │ └── services │ │ ├── observers.service.ts │ │ ├── forms.service.ts │ │ └── notifications.service.ts ├── assets │ ├── .gitkeep │ ├── .npmignore │ ├── forms │ │ ├── down.png │ │ ├── icon-delete.png │ │ ├── icon-publish.png │ │ ├── icon-reorder.png │ │ ├── diaspora-check.png │ │ ├── icon-unpublish.png │ │ ├── icon-flag-disabled.png │ │ ├── icon-flag-enabled.png │ │ ├── icon-text-disabled.png │ │ └── icon-text-enabled.png │ ├── vote_monitor.png │ ├── vote_monitor_trans_lg.png │ ├── user-icon.svg │ └── configs │ │ └── predefined-options.json ├── favicon.ico ├── environments │ ├── environment.local.ts │ ├── environment.prod.ts │ ├── environment.qa.ts │ └── environment.ts ├── typings.d.ts ├── main.ts ├── index.html ├── _variables.scss └── styles.scss ├── docker-compose.yaml ├── .editorconfig ├── .github └── CODEOWNERS ├── .gitignore ├── tsconfig.json ├── browserslist ├── package.json ├── tslint.json └── .gitattributes /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /src/app/shared/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/components/answer/answer.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/answer/answers-list/answer-list.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/observers/oberver-row/oberver-row.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/confirmation-modal/confirmation-modal.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/icon-toggle-input/icon-toggle-input.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/loading-indicator/loading-indicator.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-card/observer-card.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-details/answer-details.component.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-form-list/answer-form-list.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-value/statistics-value.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-extra-questions/answer-extra-questions.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.component'; 2 | export * from './app.module'; 3 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics.component.scss: -------------------------------------------------------------------------------- 1 | .hidden{ 2 | display:none 3 | } -------------------------------------------------------------------------------- /src/app/shared/error-indicator/error-indicator.component.scss: -------------------------------------------------------------------------------- 1 | i{ 2 | cursor: pointer; 3 | } -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/app/models/list.type.model.ts: -------------------------------------------------------------------------------- 1 | export enum ListType { 2 | CARD = 'CARD', 3 | LIST = 'LIST' 4 | } 5 | -------------------------------------------------------------------------------- /src/app/models/labelValue.model.ts: -------------------------------------------------------------------------------- 1 | export class LabelValueModel { 2 | label: string; 3 | value: any; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/forms/down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/down.png -------------------------------------------------------------------------------- /src/assets/vote_monitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/vote_monitor.png -------------------------------------------------------------------------------- /src/app/components/notifications/notification-history/notification-history.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../variables"; 2 | -------------------------------------------------------------------------------- /src/app/shared/pagination.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PaginationData{ 2 | pageSize: number; 3 | page: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/forms/icon-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-delete.png -------------------------------------------------------------------------------- /src/app/models/page-state.model.ts: -------------------------------------------------------------------------------- 1 | export enum PageState { 2 | NEW = 'new', 3 | VIEW = 'view', 4 | EDIT = 'edit' 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/forms/icon-publish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-publish.png -------------------------------------------------------------------------------- /src/assets/forms/icon-reorder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-reorder.png -------------------------------------------------------------------------------- /src/app/components/notifications/notifications.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../variables"; 2 | 3 | h5 { 4 | font-size: 1.125rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/forms/diaspora-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/diaspora-check.png -------------------------------------------------------------------------------- /src/assets/forms/icon-unpublish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-unpublish.png -------------------------------------------------------------------------------- /src/assets/vote_monitor_trans_lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/vote_monitor_trans_lg.png -------------------------------------------------------------------------------- /src/assets/forms/icon-flag-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-flag-disabled.png -------------------------------------------------------------------------------- /src/assets/forms/icon-flag-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-flag-enabled.png -------------------------------------------------------------------------------- /src/assets/forms/icon-text-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-text-disabled.png -------------------------------------------------------------------------------- /src/assets/forms/icon-text-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4romania/monitorizare-vot-ong/HEAD/src/assets/forms/icon-text-enabled.png -------------------------------------------------------------------------------- /src/app/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | sub: string; 3 | idNgo: string; 4 | userType: string; 5 | organizer: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/shared/error-indicator/error-indicator.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/store/user/user.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | export const logout = createAction( 4 | '[Header Component] Logout', 5 | ); -------------------------------------------------------------------------------- /src/app/shared/base-button/base-button.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/models/base.answer.model.ts: -------------------------------------------------------------------------------- 1 | export interface BaseAnswer { 2 | optionId: number; 3 | text: string; 4 | isFreeText: boolean; 5 | flagged: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/components/forms/predefined-options-modal/predefined-options-modal.component.scss: -------------------------------------------------------------------------------- 1 | .node-children { 2 | margin: 1.5px 1.5px 1.5px 15px; 3 | padding-left: 10px; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/models/api-list-response.model.ts: -------------------------------------------------------------------------------- 1 | export interface ApiListResponse { 2 | data: T[]; 3 | totalPages: number; 4 | totalItems: number; 5 | page: number; 6 | pageSize: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/models/base.question.model.ts: -------------------------------------------------------------------------------- 1 | export interface BaseQuestion { 2 | id: number; 3 | formCode: string; 4 | code: string; 5 | text: string; 6 | idQuestionType: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/models/answer.filters.model.ts: -------------------------------------------------------------------------------- 1 | export class AnswerFilters { 2 | observerPhoneNumber: number = null; 3 | pollingStationNumber: string = null; 4 | county: string = null; 5 | urgent = false; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/models/answer.thread.model.ts: -------------------------------------------------------------------------------- 1 | export class AnswerThread { 2 | public pollingStationName: string; 3 | public observerName: string; 4 | public pollingStationId: number; 5 | public observerId: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/components/forms/option/option.component.scss: -------------------------------------------------------------------------------- 1 | .icon-holder { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-around; 5 | 6 | cursor: pointer; 7 | 8 | margin: auto 4px; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/app/shared/loading-indicator/loading-indicator.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Loading... 4 |
5 |
6 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | services: 4 | frontend: 5 | image: node:14 6 | working_dir: /app 7 | ports: 8 | - "4200:4200" 9 | command: "sh -c 'yarn && npm start -- --host 0.0.0.0'" 10 | volumes: 11 | - ./:/app 12 | -------------------------------------------------------------------------------- /src/app/models/form.section.model.ts: -------------------------------------------------------------------------------- 1 | import { FormQuestion } from './form.question.model'; 2 | 3 | export interface FormSection { 4 | uniqueId: string; 5 | id: number; 6 | code: string; 7 | description: string; 8 | questions: FormQuestion[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/table/table-column/table-column.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, TemplateRef } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[tableColumn]' 5 | }) 6 | export class TableColumnDirective { 7 | constructor (public templateRef: TemplateRef) { } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-profile/observers-profile-upload.form.ts: -------------------------------------------------------------------------------- 1 | import {FormGroup} from '@angular/forms'; 2 | 3 | export class ObserverProfileUploadForm extends FormGroup { 4 | constructor() { 5 | super({ 6 | csv: null 7 | }); 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/app/shared/base-checkbox/base-checkbox.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/components/answer/categorical-question/categorical-question.component.scss: -------------------------------------------------------------------------------- 1 | .flagged-question { 2 | border: 1px solid red; 3 | } 4 | .flagged-question { 5 | .question-title { 6 | color: red; 7 | } 8 | } 9 | .flagged-answer { 10 | .question-answer { 11 | color: red; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/models/completed.answer.model.ts: -------------------------------------------------------------------------------- 1 | import { BaseAnswer } from './base.answer.model'; 2 | 3 | export interface CompletedAnswer extends BaseAnswer { 4 | value: string; 5 | flagged: boolean; 6 | } 7 | 8 | export interface CompletedAnswerMap { 9 | [prop: number]: CompletedAnswer; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/models/form.info.model.ts: -------------------------------------------------------------------------------- 1 | export interface FormInfo { 2 | formVersions: FormDetails[]; 3 | } 4 | 5 | export interface FormDetails { 6 | id: number; 7 | code: string; 8 | description: string; 9 | currentVersion: number; 10 | diaspora: boolean; 11 | draft: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/store/county/county.state.ts: -------------------------------------------------------------------------------- 1 | export interface CountyState { 2 | counties: County[]; 3 | errorMessage?: string; 4 | } 5 | 6 | export interface County { 7 | name: string; 8 | code: string; 9 | numberOfPollingStations: number; 10 | id: number; 11 | diaspora: boolean; 12 | order: number; 13 | } -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-value/statistics-value.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{index}}
4 |
{{label}}
5 |
6 |
{{value}}
7 |
-------------------------------------------------------------------------------- /src/app/components/answer/answer-note/answer-note.component.scss: -------------------------------------------------------------------------------- 1 | .clear { 2 | clear:both; 3 | } 4 | .content-container{ 5 | .note-image{ 6 | max-width:300px; 7 | margin:10px; 8 | float:right; 9 | } 10 | .note-text{ 11 | clear:right; 12 | float:left 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/table/table-column/table-column.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { TableColumnDirective } from './table-column.directive'; 2 | 3 | describe('TableColumnDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new TableColumnDirective(); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentConfig } from '../typings'; 2 | 3 | export const environment: EnvironmentConfig = { 4 | production: false, 5 | observerGuideUrl: 6 | 'https://votcorect.ro/wp-content/uploads/2020/09/Manualul-observatorului-locale-2020-public.pdf', 7 | apiUrl: 'https://localhost:5001', 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/store/observers/observers.config.ts: -------------------------------------------------------------------------------- 1 | export interface ObserversStateConfig { 2 | key: string; 3 | method: string; 4 | header: string; 5 | subHeader: string; 6 | } 7 | export let observersConfig = [{ 8 | key: 'observers-list', 9 | method: '', 10 | header: 'Observatori', 11 | subHeader: '', 12 | }]; 13 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentConfig } from '../typings'; 2 | 3 | export const environment: EnvironmentConfig = { 4 | production: true, 5 | observerGuideUrl: 6 | 'https://fiecarevot.ro/wp-content/uploads/2020/12/Manual-observatori-FV-parlamentare2020.pdf', 7 | apiUrl: 'https://api.votemonitor.org/', 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-details/statistics-details.component.html: -------------------------------------------------------------------------------- 1 | 5 | 8 | -------------------------------------------------------------------------------- /src/app/shared/pagination.service.ts: -------------------------------------------------------------------------------- 1 | export function shouldLoadPage(page: number, pageSize: number, arrayLen) { 2 | if (page === undefined || pageSize === undefined) { 3 | return true; 4 | } 5 | if (page * pageSize > arrayLen) { 6 | return !((page - 1) * pageSize < arrayLen); 7 | } else { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/store/county/county.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { CountyState } from './county.state'; 3 | 4 | export const county = createFeatureSelector('county'); 5 | 6 | export const getCounties = createSelector( 7 | county, 8 | (state: CountyState) => state?.counties, 9 | ) -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/environments/environment.qa.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentConfig } from '../typings'; 2 | 3 | export const environment: EnvironmentConfig = { 4 | production: true, 5 | observerGuideUrl: 6 | 'https://fiecarevot.ro/wp-content/uploads/2020/12/Manual-observatori-FV-parlamentare2020.pdf', 7 | apiUrl: 'https://monitorizare-vot-api.staging.heroesof.tech/', 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/shared/confirmation-modal/confirmation-modal.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | // Typings reference file, you can add your own global typings here 2 | // https://www.typescriptlang.org/docs/handbook/writing-declaration-files.html 3 | 4 | export interface EnvironmentConfig { 5 | production: boolean; 6 | observerGuideUrl: string; 7 | apiUrl: string; 8 | answersRefreshTimeMs?: number; 9 | pageSize?: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/models/note.model.ts: -------------------------------------------------------------------------------- 1 | export class Note { 2 | countyCode: string; 3 | pollingStattionNumber: number; 4 | attachmentsPaths: ({ src: string; isImage: boolean, type?: string; })[]; 5 | text: string; 6 | formCode: string; 7 | formId: number; 8 | questionId: number; 9 | } 10 | 11 | export interface NoteMap { 12 | [prop: number]: Note; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/store/util.ts: -------------------------------------------------------------------------------- 1 | 2 | const typeCache: { [label: string]: boolean } = {}; 3 | export function actionType(label: T): T { 4 | if (typeCache[label as string]) { 5 | throw new Error(`Action type "${label}" is not unqiue"`); 6 | } 7 | 8 | typeCache[label as string] = true; 9 | 10 | return label as T; 11 | } 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app/shared/icon-toggle-input/icon-toggle-input.component.html: -------------------------------------------------------------------------------- 1 |
2 | 4 | 6 |
7 | -------------------------------------------------------------------------------- /src/app/store/meta-reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, MetaReducer } from '@ngrx/store'; 2 | import { logout } from '../user/user.actions'; 3 | 4 | const resetStateAfterLogout = (reducer: ActionReducer) => (state, action) => { 5 | return action.type === logout.type ? reducer(undefined, {}) : reducer(state, action); 6 | }; 7 | 8 | export const metaReducers: MetaReducer[] = [resetStateAfterLogout]; -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # they will be requested for review when someone 4 | # opens a pull request. 5 | * @aniri @idormenco @bogdanconstantinescu @Utwo 6 | 7 | # More details on creating a codeowners file: 8 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { enableProdMode } from '@angular/core'; 3 | import { environment } from 'src/environments/environment'; 4 | import { AppModule } from './app'; 5 | 6 | if (environment.production) { 7 | enableProdMode(); 8 | } 9 | 10 | platformBrowserDynamic() 11 | .bootstrapModule(AppModule) 12 | .catch((err) => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/app/components/observers/oberver-row/oberver-row.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { BaseObserverCrudComponent } from '../base-observer-crud.component'; 3 | 4 | @Component({ 5 | selector: '[app-oberver-row]', 6 | templateUrl: './oberver-row.component.html', 7 | styleUrls: ['./oberver-row.component.scss'] 8 | }) 9 | export class OberverRowComponent extends BaseObserverCrudComponent { 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/utils.ts: -------------------------------------------------------------------------------- 1 | import {FormArray} from '@angular/forms'; 2 | 3 | export function moveItemInFormArray(source: FormArray, from: number, to: number) { 4 | const dir = to > from ? 1 : -1; 5 | 6 | const temp = source.at(from); 7 | for (let i = from; i * dir < to * dir; i = i + dir) { 8 | const current = source.at(i + dir); 9 | source.setControl(i, current); 10 | } 11 | source.setControl(to, temp); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/loading-indicator/loading-indicator.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-loading-indicator', 5 | templateUrl: './loading-indicator.component.html', 6 | styleUrls: ['./loading-indicator.component.scss'] 7 | }) 8 | export class LoadingIndicatorComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-card/observer-card.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { BaseObserverCrudComponent } from '../base-observer-crud.component'; 3 | 4 | @Component({ 5 | selector: 'app-observers-card', 6 | templateUrl: './observer-card.component.html', 7 | styleUrls: ['./observer-card.component.scss'] 8 | }) 9 | export class ObserverCardComponent extends BaseObserverCrudComponent { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/observers/observers-filter.form.ts: -------------------------------------------------------------------------------- 1 | import { FormGroup, FormControl } from '@angular/forms'; 2 | 3 | export class ObserversFilterForm extends FormGroup { 4 | constructor() { 5 | super({ 6 | name: new FormControl(''), 7 | phone: new FormControl('') 8 | }); 9 | } 10 | 11 | isEmpty(){ 12 | return !Object.keys(this.controls).some(e => this.get(e).value.length); 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/app/core/anon.guard.ts: -------------------------------------------------------------------------------- 1 | import { TokenService } from './token/token.service'; 2 | import { Injectable } from '@angular/core'; 3 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; 4 | @Injectable() 5 | export class AnonGuard implements CanActivate { 6 | constructor(private tokenService: TokenService) {} 7 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 8 | return !this.tokenService.token; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/models/completed.question.model.ts: -------------------------------------------------------------------------------- 1 | import { CompletedAnswer } from './completed.answer.model'; 2 | import { BaseQuestion } from './base.question.model'; 3 | 4 | export interface CompletedQuestion extends BaseQuestion { 5 | answers: CompletedAnswer[]; 6 | } 7 | 8 | export interface CompletedQuestionMapped extends BaseQuestion { 9 | answers: {[prop: number]: CompletedAnswer}; 10 | } 11 | 12 | export interface CompletedQuestionMap { 13 | [prop: number]: CompletedQuestionMapped 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/error-indicator/error-indicator.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, EventEmitter, OnInit, Output} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-error-indicator', 5 | templateUrl: './error-indicator.component.html', 6 | styleUrls: ['./error-indicator.component.scss'] 7 | }) 8 | export class ErrorIndicatorComponent implements OnInit { 9 | 10 | @Output() 11 | retry = new EventEmitter(); 12 | 13 | constructor() { } 14 | 15 | ngOnInit() { 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-details/statistics-details.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../variables"; 2 | .card-header { 3 | background: $gray; 4 | padding: 10px 25px; 5 | height: 100px; 6 | display: flex; 7 | } 8 | .card-header-contents { 9 | margin: auto 0; 10 | } 11 | .card-row { 12 | &:nth-child(2n) { 13 | background: $gray; 14 | } 15 | } 16 | .card-footer { 17 | padding: 10px 25px; 18 | a { 19 | text-decoration: underline; 20 | color: blue; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/components/forms/option/option.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, EventEmitter, Input, Output} from '@angular/core'; 2 | import {FormGroup} from '@angular/forms'; 3 | 4 | @Component({ 5 | selector: 'app-option', 6 | templateUrl: './option.component.html', 7 | styleUrls: ['./option.component.scss'] 8 | }) 9 | 10 | export class OptionComponent { 11 | @Input() optionFormGroup: FormGroup; 12 | 13 | @Output() optionDeleteEventEmitter = new EventEmitter(); 14 | 15 | constructor() { } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-form-list/answer-form-list.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ section.description }}

3 |
4 | 9 | 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 |
8 |
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-note/answer-note.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | {{note.text}} 6 |

7 |
8 | 9 | 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-card/statistics-card.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../variables"; 2 | .card-header { 3 | background:#fff; 4 | padding: 10px 25px; 5 | height: 100px; 6 | display: flex; 7 | } 8 | .card-header-contents { 9 | margin: auto 0; 10 | } 11 | .card-row { 12 | &:nth-child(odd) { 13 | background:$gray-light; 14 | } 15 | } 16 | .card-footer { 17 | padding: 10px 25px; 18 | background-color:#fff; 19 | a { 20 | color: $primary; 21 | cursor: pointer; 22 | } 23 | } 24 | .dialog { 25 | margin: -1rem; 26 | } -------------------------------------------------------------------------------- /src/app/components/answer/answer-extra-questions/answer-extra-questions.component.ts: -------------------------------------------------------------------------------- 1 | import { AnswerExtra } from '../../../models/answer.extra.model'; 2 | import { Component, Input, OnInit } from '@angular/core'; 3 | @Component({ 4 | selector: 'app-answer-extra-questions', 5 | templateUrl: './answer-extra-questions.component.html', 6 | styleUrls: ['./answer-extra-questions.component.scss'] 7 | }) 8 | export class AnswerExtraQuestionsComponent implements OnInit{ 9 | 10 | @Input() 11 | answerExtra: AnswerExtra; 12 | ngOnInit(){ 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/routing/guards/home.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; 3 | @Injectable() 4 | export class HomeGuard implements CanActivate 5 | { 6 | constructor(private router: Router) { } 7 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 8 | this.router.navigate(['/answers'], { 9 | queryParams: { 10 | urgente: true 11 | } 12 | }); 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # IDEs and editors 12 | /.idea 13 | /.vscode 14 | .project 15 | .classpath 16 | *.launch 17 | .settings/ 18 | 19 | # misc 20 | /package-lock.json 21 | /.sass-cache 22 | /connect.lock 23 | /coverage/* 24 | /libpeerconnection.log 25 | npm-debug.log 26 | testem.log 27 | /typings 28 | 29 | # e2e 30 | /e2e/*.js 31 | /e2e/*.map 32 | 33 | #System Files 34 | .DS_Store 35 | Thumbs.db 36 | -------------------------------------------------------------------------------- /src/app/shared/confirmation-modal/confirmation-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 3 | 4 | @Component({ 5 | selector: 'app-confirmation-modal', 6 | templateUrl: './confirmation-modal.component.html', 7 | styleUrls: ['./confirmation-modal.component.css'] 8 | }) 9 | export class ConfirmationModalComponent implements OnInit { 10 | 11 | @Input() 12 | public message: string; 13 | 14 | constructor(public modal: NgbActiveModal) { } 15 | 16 | ngOnInit() { 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-value/statistics-value.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-statistics-value', 5 | styleUrls: ['./statistics-value.component.scss'], 6 | templateUrl: './statistics-value.component.html' 7 | }) 8 | export class StatisticsValueComponent implements OnInit { 9 | 10 | @Input() 11 | label: string; 12 | 13 | @Input() 14 | value: string; 15 | 16 | @Input() 17 | index: number; 18 | 19 | 20 | constructor() { } 21 | 22 | ngOnInit() { } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/table/table.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { TableContainerComponent } from './table-container/table-container.component'; 4 | import { SharedModule } from '../shared/shared.module'; 5 | import { TableColumnDirective } from './table-column/table-column.directive'; 6 | 7 | @NgModule({ 8 | exports: [TableContainerComponent, TableColumnDirective], 9 | declarations: [TableContainerComponent, TableColumnDirective], 10 | imports: [ 11 | CommonModule, 12 | SharedModule, 13 | ], 14 | }) 15 | export class TableModule { } 16 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Monitorizare Vot 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | Loading... 20 |
21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "declaration": false, 5 | "downlevelIteration": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": ["es2019", "dom"], 9 | "mapRoot": "./", 10 | "target": "es2015", 11 | "module": "es2020", 12 | "moduleResolution": "node", 13 | "outDir": "../dist/out-tsc", 14 | "sourceMap": true, 15 | "importHelpers": true, 16 | "typeRoots": ["../node_modules/@types"], 17 | "resolveJsonModule": true, 18 | "esModuleInterop": true 19 | }, 20 | "files": ["src/main.ts", "src/polyfills.ts"], 21 | "include": ["src/**/*.d.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/app/store/notifications/notifications.state.ts: -------------------------------------------------------------------------------- 1 | import {HistoryNotifications} from '../../models/notification.model'; 2 | import {AppState} from '../store.module'; 3 | import {createSelector} from '@ngrx/store'; 4 | 5 | export interface NotificationsState extends HistoryNotifications { 6 | loading: boolean; 7 | } 8 | 9 | export const notificationsInitialState: NotificationsState = { 10 | data: [], 11 | page: 0, 12 | pageSize: 0, 13 | totalPages: 0, 14 | totalItems: 0, 15 | loading: false 16 | }; 17 | 18 | export const selectNotifications = (s: AppState) => s.notifications; 19 | export const selectNotificationData = createSelector(selectNotifications, s => s.data); 20 | -------------------------------------------------------------------------------- /src/app/routing/guards/load-answer-list.guard.ts: -------------------------------------------------------------------------------- 1 | 2 | import {map, take} from 'rxjs/operators'; 3 | import { LoadAnswerPreviewAction } from '../../store/answer/answer.actions'; 4 | import { AppState } from '../../store/store.module'; 5 | import {select, Store} from '@ngrx/store'; 6 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; 7 | import { Injectable } from '@angular/core'; 8 | 9 | @Injectable() 10 | export class AnswerListGuard implements CanActivate { 11 | constructor(private store: Store) { } 12 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 13 | 14 | 15 | return true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/_variables.scss: -------------------------------------------------------------------------------- 1 | $bg-global: #f8f8f8 /* #f2f2f2 */; 2 | $primary: #5e288d; 3 | $secondary: #333; 4 | $yellow: #fbd844; 5 | $purple: #5f288f; 6 | $danger: #fd0001; 7 | $gray: #ddd2e7; 8 | $gray-dark: #7b8184; 9 | $gray-light: #eee; 10 | $base-gray: #AFAFAF; 11 | $light-gray: #F3F1F1; 12 | $muted-gray: #B6B6B6; 13 | $readable-dark-gray: #6B6B6B; 14 | $darker-gray: #454545; 15 | $dim-gray: #636363; 16 | $border-color-gray: #EDEDED; 17 | $light-text-gray: #7C8284; 18 | 19 | $primary-purple: #5F288D; 20 | $delete-color: #F16359; 21 | 22 | $font-weight-medium: 500; 23 | $primary-gray: #707070; 24 | 25 | $danger: $delete-color; 26 | 27 | $input-text-color: #A3A3A3; 28 | $selected-row-color: #F9F9FA; 29 | 30 | -------------------------------------------------------------------------------- /src/app/store/notifications/notifications.reducer.ts: -------------------------------------------------------------------------------- 1 | import {NotificationsActions} from './notifications.actions'; 2 | import {notificationsInitialState} from './notifications.state'; 3 | import {createReducer, on} from '@ngrx/store'; 4 | import {HistoryNotifications} from '../../models/notification.model'; 5 | 6 | export const notificationsReducer = createReducer( 7 | notificationsInitialState, 8 | on(NotificationsActions.load, state => ({...state, loading: true})), 9 | on(NotificationsActions.loaded, (state, newState: HistoryNotifications) => ({...state, ...newState, loading: false})), 10 | on(NotificationsActions.error, state => ({...state, ...notificationsInitialState, loading: false})) 11 | ) 12 | -------------------------------------------------------------------------------- /src/app/answers/answers.model.ts: -------------------------------------------------------------------------------- 1 | import { FormQuestion } from 'src/app/models/form.question.model'; 2 | import {Note, NoteMap} from 'src/app/models/note.model'; 3 | import {CompletedQuestionMap} from '../models/completed.question.model'; 4 | 5 | export interface SectionsState { 6 | flaggedQuestions: { [k: number]: boolean }, 7 | selectedAnswers: CompletedQuestionMap, 8 | formNotes: NoteMap, 9 | } 10 | 11 | export interface DisplayedNote extends Note { 12 | hasCorrespondingQuestion: boolean; 13 | tabName?: string; 14 | questionCode?: string; 15 | isQuestionFlagged?: boolean; 16 | } 17 | 18 | // export interface NoteWithoutQuestion extends Note { 19 | // hasCorrespondingQuestion: false; 20 | // } 21 | -------------------------------------------------------------------------------- /src/assets/user-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-note/answer-note.component.ts: -------------------------------------------------------------------------------- 1 | import { BaseQuestion } from '../../../models/base.question.model'; 2 | import { Note } from '../../../models/note.model'; 3 | 4 | import { Component, Input, OnInit } from '@angular/core'; 5 | 6 | @Component({ 7 | selector: 'app-answer-note', 8 | templateUrl: './answer-note.component.html', 9 | styleUrls: ['./answer-note.component.scss'] 10 | }) 11 | export class AnswerNoteComponent implements OnInit { 12 | 13 | @Input() 14 | note: Note; 15 | 16 | @Input() 17 | question: BaseQuestion; 18 | 19 | 20 | get canShow(){ 21 | return this.note && this.question; 22 | } 23 | 24 | 25 | 26 | constructor() { } 27 | 28 | ngOnInit() { 29 | 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/app/routing/guards/load-anwer-details.guard.ts: -------------------------------------------------------------------------------- 1 | import { LoadAnswerDetailsAction} from '../../store/answer/answer.actions'; 2 | import { AppState } from '../../store/store.module'; 3 | import { Store } from '@ngrx/store'; 4 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; 5 | import { Injectable } from '@angular/core'; 6 | 7 | @Injectable() 8 | export class AnswerDetailsGuard implements CanActivate { 9 | constructor(private store: Store) {} 10 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 11 | this.store.dispatch(new LoadAnswerDetailsAction(route.params['observerId'], route.params['pollingStationId'])); 12 | return true; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `angular-cli.json`. 5 | 6 | import { EnvironmentConfig } from '../typings'; 7 | 8 | export const environment: EnvironmentConfig = { 9 | production: true, 10 | observerGuideUrl: 11 | 'https://fiecarevot.ro/wp-content/uploads/2020/12/Manual-observatori-FV-parlamentare2020.pdf', 12 | apiUrl: 'https://api.votemonitor.org/', 13 | answersRefreshTimeMs: 60000, 14 | pageSize: 10, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-profile/observer-profile.form.ts: -------------------------------------------------------------------------------- 1 | import { FormGroup, FormControl, Validators } from '@angular/forms'; 2 | import { Observer } from '../../../models/observer.model'; 3 | 4 | export class ObserverProfileForm extends FormGroup { 5 | constructor() { 6 | super({ 7 | name: new FormControl('', Validators.required), 8 | phone: new FormControl('', [Validators.required]), 9 | password: new FormControl(''), 10 | sendSMS: new FormControl(false), 11 | }); 12 | } 13 | 14 | isFieldValid(fieldName: ObserverProfileField): boolean { 15 | const field = this.get(fieldName); 16 | return field.valid && field.dirty; 17 | } 18 | } 19 | 20 | export type ObserverProfileField = keyof Observer; 21 | -------------------------------------------------------------------------------- /src/app/store/notifications/notifications.actions.ts: -------------------------------------------------------------------------------- 1 | import {actionType} from '../util'; 2 | import {createAction, props} from '@ngrx/store'; 3 | import {HistoryNotifications} from '../../models/notification.model'; 4 | 5 | export class NotificationsActions { 6 | static LOAD_ACTION = actionType('[Notifications] Load'); 7 | static LOADED_ACTION = actionType('[Notifications] Loaded'); 8 | static ERROR_ACTION = actionType('[Notifications] Load Error'); 9 | 10 | static load = createAction(NotificationsActions.LOAD_ACTION, props<{page: number, pageSize: number}>()); 11 | static loaded = createAction(NotificationsActions.LOADED_ACTION, props()); 12 | static error = createAction(NotificationsActions.ERROR_ACTION); 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/app/answers/answer-notification/answer-notification.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .card { 4 | background: #FFFFFF; 5 | box-shadow: 4px 4px 30px rgba(0, 0, 0, 0.08); 6 | border-radius: 4px; 7 | border: none; 8 | } 9 | 10 | .c-notif-body { 11 | width: 50%; 12 | @media all and (max-width: 600px) { 13 | & { 14 | width: 100%; 15 | } 16 | } 17 | 18 | .form-control { 19 | border: 1px solid $border-color-gray; 20 | border-radius: 4px; 21 | } 22 | 23 | .form-group label { 24 | color: $dim-gray; 25 | } 26 | } 27 | 28 | .c-notif { 29 | &__title { 30 | color: $darker-gray; 31 | } 32 | } 33 | 34 | .c-notif-buttons { 35 | margin-top: 2.5rem; 36 | column-gap: 1rem; 37 | display: flex; 38 | } -------------------------------------------------------------------------------- /src/app/answers/notes/notes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NotesComponent } from './notes.component'; 4 | 5 | describe('NotesComponent', () => { 6 | let component: NotesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ NotesComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(NotesComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/answers/answers/answers.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AnswersComponent } from './answers.component'; 4 | 5 | describe('AnswersComponent', () => { 6 | let component: AnswersComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AnswersComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AnswersComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.scss'], 8 | }) 9 | export class AppComponent { 10 | public languages: string[] = ['en', 'ro']; 11 | public langIndex = 0; 12 | constructor(translate: TranslateService) { 13 | const lang = localStorage.getItem('language'); 14 | if (lang) { 15 | this.langIndex = this.languages.findIndex((x) => x === lang); 16 | } else { 17 | localStorage.setItem('language', 'ro'); 18 | } 19 | translate.addLangs(this.languages); 20 | translate.setDefaultLang(lang || 'ro'); 21 | translate.use(lang || 'ro'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/models/notification.model.ts: -------------------------------------------------------------------------------- 1 | export interface HistoryNotifications { 2 | data: HistoryNotificationModel[]; 3 | page: number; 4 | pageSize: number; 5 | totalPages: number; 6 | totalItems: number; 7 | } 8 | 9 | export interface HistoryNotificationModel { 10 | id: number; 11 | title: string; 12 | body: string; 13 | channel: string; 14 | insertedAt: string; 15 | senderId: number; 16 | senderIdNgo: number; 17 | senderNgoName: string; 18 | senderAccount: string; 19 | sentObserverIds: number[]; 20 | } 21 | 22 | export interface SentNotificationModel extends SentGlobalNotificationModel { 23 | recipients: string[]; 24 | } 25 | 26 | export interface SentGlobalNotificationModel { 27 | channel: string; 28 | from: string; 29 | title: string; 30 | message: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/routing/app.routing.module.ts: -------------------------------------------------------------------------------- 1 | import { appRoutes } from './app.routes'; 2 | import { RouterModule } from '@angular/router'; 3 | import { LoadStatisticsGuard } from './guards/load-statistics.guard'; 4 | import { AnswerDetailsGuard } from './guards/load-anwer-details.guard'; 5 | import { AnswerListGuard } from './guards/load-answer-list.guard'; 6 | import { HomeGuard } from './guards/home.guard'; 7 | import { NgModule } from '@angular/core'; 8 | @NgModule({ 9 | imports: [ 10 | RouterModule.forRoot(appRoutes, { 11 | enableTracing: false, 12 | // enableTracing: !environment.production 13 | }), 14 | ], 15 | providers: [ 16 | HomeGuard, 17 | AnswerListGuard, 18 | AnswerDetailsGuard, 19 | LoadStatisticsGuard, 20 | ], 21 | }) 22 | export class AppRoutingModule {} 23 | -------------------------------------------------------------------------------- /src/app/shared/base-button/base-button.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BaseButtonComponent } from './base-button.component'; 4 | 5 | describe('BaseButtonComponent', () => { 6 | let component: BaseButtonComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ BaseButtonComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(BaseButtonComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/models/form.question.model.ts: -------------------------------------------------------------------------------- 1 | import { BaseAnswer } from './base.answer.model'; 2 | import {BaseQuestion} from './base.question.model'; 3 | 4 | export interface FormQuestion extends BaseQuestion { 5 | id: number; 6 | formCode: string; 7 | code: string; 8 | idSection: number; 9 | questionType: number; 10 | text: string; 11 | hint: string; 12 | optionsToQuestions: BaseAnswer[]; 13 | } 14 | 15 | export const QUESTION_TYPES = [ 16 | { 17 | id: 0, 18 | name: 'MULTIPLE_CHOICE' 19 | }, 20 | { 21 | id: 1, 22 | name: 'SINGLE_CHOICE' 23 | }, 24 | { 25 | id: 2, 26 | name: 'SINGLE_CHOICE_TEXT' 27 | }, 28 | { 29 | id: 3, 30 | name: 'MULTIPLE_CHOICE_TEXT' 31 | } 32 | ]; 33 | 34 | export interface QuestionType { 35 | id: number; 36 | name: string; 37 | } 38 | -------------------------------------------------------------------------------- /src/app/shared/base-checkbox/base-checkbox.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BaseCheckboxComponent } from './base-checkbox.component'; 4 | 5 | describe('BaseCheckboxComponent', () => { 6 | let component: BaseCheckboxComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ BaseCheckboxComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(BaseCheckboxComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/shared/base-dropdown/base-dropdown.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BaseDropdownComponent } from './base-dropdown.component'; 4 | 5 | describe('BaseDropdownComponent', () => { 6 | let component: BaseDropdownComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ BaseDropdownComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(BaseDropdownComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/answers/answer-details/answer-details.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AnswerDetailsComponent } from './answer-details.component'; 4 | 5 | describe('AnswerDetailsComponent', () => { 6 | let component: AnswerDetailsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AnswerDetailsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AnswerDetailsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/components/counties/counties.component.scss: -------------------------------------------------------------------------------- 1 | .btn-group { 2 | app-base-button { 3 | margin-left:1rem; 4 | } 5 | } 6 | .filter-container { 7 | margin:1rem; 8 | } 9 | 10 | .form-control { 11 | width:350px; 12 | } 13 | 14 | .w-15 { 15 | width: 15%; 16 | } 17 | 18 | .no-wrap { 19 | white-space: nowrap; 20 | } 21 | 22 | .centered-cell { 23 | text-align: center; 24 | vertical-align: middle; 25 | } 26 | 27 | .cdk-drop-list-dragging .cdk-drag { 28 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 29 | } 30 | 31 | .cdk-drag-animating { 32 | transition: transform 300ms cubic-bezier(0, 0, 0.2, 1); 33 | } 34 | 35 | .cdk-drag-placeholder { 36 | opacity: 0; 37 | } 38 | 39 | .cdk-drag-preview { 40 | box-sizing: border-box; 41 | border-radius: 4px; 42 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-import/observer-import.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../variables'; 2 | 3 | $label-text-color: $dim-gray; 4 | $border-color: $border-color-gray; 5 | 6 | .c-import { 7 | &__title { 8 | color: $darker-gray; 9 | } 10 | 11 | 12 | &__card-body { 13 | width: 50%; 14 | 15 | @media all and (max-width: 700px) { 16 | & { 17 | width: 100%; 18 | } 19 | } 20 | 21 | @media all and (min-width: 700px) and (max-width: 920px) { 22 | & { 23 | width: 75%; 24 | } 25 | } 26 | 27 | input.form-control[class] { 28 | padding: 1.4rem 0.7rem; 29 | border-radius: 6px; 30 | border: 1px solid $border-color; 31 | } 32 | } 33 | } 34 | 35 | .form-group { 36 | label { 37 | color: $label-text-color; 38 | font-size: 1.15rem; 39 | } 40 | } -------------------------------------------------------------------------------- /src/app/table/table-container/table-container.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TableContainerComponent } from './table-container.component'; 4 | 5 | describe('TableContainerComponent', () => { 6 | let component: TableContainerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ TableContainerComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(TableContainerComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/answers/answer-questions/answer-questions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AnswerQuestionsComponent } from './answer-questions.component'; 4 | 5 | describe('AnswerQuestionsComponent', () => { 6 | let component: AnswerQuestionsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AnswerQuestionsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AnswerQuestionsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-import/observer-import.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ObserverImportComponent } from './observer-import.component'; 4 | 5 | describe('ObserverImportComponent', () => { 6 | let component: ObserverImportComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ObserverImportComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ObserverImportComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/answers/answer-notification/answer-notification.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AnswerNotificationComponent } from './answer-notification.component'; 4 | 5 | describe('AnswerNotificationComponent', () => { 6 | let component: AnswerNotificationComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AnswerNotificationComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AnswerNotificationComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/assets/configs/predefined-options.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "PREDEFINED_OPTIONS.CONFIRMATION.LABEL", 4 | "options": [ 5 | { 6 | "label": "PREDEFINED_OPTIONS.CONFIRMATION.YES" 7 | }, 8 | { 9 | "label": "PREDEFINED_OPTIONS.CONFIRMATION.NO" 10 | }, 11 | { 12 | "label": "PREDEFINED_OPTIONS.CONFIRMATION.DONT_KNOW" 13 | } 14 | ] 15 | }, 16 | { 17 | "label": "PREDEFINED_OPTIONS.RATING.LABEL", 18 | "options": [ 19 | { 20 | "label": "PREDEFINED_OPTIONS.RATING.1" 21 | }, 22 | { 23 | "label": "PREDEFINED_OPTIONS.RATING.2" 24 | }, 25 | { 26 | "label": "PREDEFINED_OPTIONS.RATING.3" 27 | }, 28 | { 29 | "label": "PREDEFINED_OPTIONS.RATING.4" 30 | }, 31 | { 32 | "label": "PREDEFINED_OPTIONS.RATING.5" 33 | } 34 | ] 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /src/app/store/county/county.reducer.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CountyState } from "./county.state"; 3 | import { CountyActions, CountyActionTypes } from "./county.actions"; 4 | 5 | 6 | const initialCountyState: CountyState = { 7 | counties: undefined, 8 | }; 9 | 10 | export function countyReducer(state = initialCountyState, $action: CountyActions) { 11 | switch ($action.type) { 12 | case CountyActionTypes.FETCH_COUNTIES_SUCCESS: 13 | return { ...state, counties: $action.counties }; 14 | case CountyActionTypes.FETCH_COUNTIES_FAILURE: 15 | return { ...state, errorMessage: $action.errorMessage }; 16 | case CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS_SUCCESS: 17 | return { ...state, counties: $action.counties }; 18 | case CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS_FAILURE: 19 | return { ...state, errorMessage: $action.errorMessage }; 20 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/components/forms/predefined-options-modal/predefined-options-modal.component.html: -------------------------------------------------------------------------------- 1 | 4 | 14 | 18 | -------------------------------------------------------------------------------- /src/app/models/form.model.ts: -------------------------------------------------------------------------------- 1 | import { FormSection } from './form.section.model'; 2 | import {FormDetails} from './form.info.model'; 3 | 4 | export class Form implements FormDetails { 5 | id: number; 6 | formSections: FormSection[]; 7 | description: string; 8 | code: string; 9 | diaspora: boolean; 10 | draft: boolean; 11 | currentVersion: number; 12 | 13 | public static fromMetaData(formDetails: FormDetails) { 14 | const result = new Form(); 15 | result.inheritMetaData(formDetails); 16 | return result; 17 | } 18 | 19 | public inheritMetaData(formDetails: FormDetails) { 20 | this.id = formDetails.id; 21 | this.description = formDetails.description; 22 | this.code = formDetails.code; 23 | this.diaspora = formDetails.diaspora; 24 | this.currentVersion = formDetails.currentVersion; 25 | this.draft = formDetails.draft; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/shared/base-dropdown/base-dropdown.component.scss: -------------------------------------------------------------------------------- 1 | $selected-foreground-color: #707070; 2 | $selected-bg-color: rgba(0, 0, 0, 0.04); 3 | $arrow-focused-color: #E8E8E8; 4 | 5 | :host { 6 | display: inline-grid; 7 | grid-auto-rows: 1fr; 8 | grid-template-columns: 3fr 2fr; 9 | } 10 | 11 | .c-dropdown__container { 12 | margin-left: .2rem; 13 | } 14 | 15 | .c-dropdown__arrow { 16 | width: 100%; 17 | height: 100%; 18 | 19 | &:focus { 20 | background-color: $arrow-focused-color; 21 | } 22 | } 23 | 24 | .dropdown-menu { 25 | // with the default rule `will-change: transform;` 26 | // the dropdown list will be a bit blurry 27 | will-change: initial !important; 28 | } 29 | 30 | .c-dropdown__item[class]:hover { 31 | background-color: $selected-bg-color; 32 | } 33 | 34 | .c-dropdown__item[class] { 35 | color: $selected-foreground-color; 36 | padding-top: 0.6rem; 37 | padding-bottom: .6rem; 38 | } -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. 18 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 19 | -------------------------------------------------------------------------------- /src/app/answers/answers-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { AnswerDetailsComponent } from './answer-details/answer-details.component'; 4 | import { AnswerNotificationComponent } from './answer-notification/answer-notification.component'; 5 | import { AnswersComponent } from './answers/answers.component'; 6 | 7 | 8 | const routes: Routes = [ 9 | { 10 | path: '', 11 | pathMatch: 'full', 12 | component: AnswersComponent, 13 | }, 14 | { 15 | path: ':observerId/:pollingStationId', 16 | component: AnswerDetailsComponent 17 | }, 18 | { 19 | path: ':observerId/:pollingStationId/notification', 20 | component: AnswerNotificationComponent, 21 | }, 22 | ]; 23 | 24 | @NgModule({ 25 | imports: [RouterModule.forChild(routes)], 26 | exports: [RouterModule] 27 | }) 28 | export class AnswersRoutingModule { } 29 | -------------------------------------------------------------------------------- /src/app/store/notifications/notifications.effects.ts: -------------------------------------------------------------------------------- 1 | import {Actions, createEffect, ofType} from '@ngrx/effects'; 2 | import {NotificationsService} from '../../services/notifications.service'; 3 | import {NotificationsActions} from './notifications.actions'; 4 | import {catchError, map, mergeMap} from 'rxjs/operators'; 5 | import {Injectable} from '@angular/core'; 6 | 7 | @Injectable() 8 | export class NotificationsEffects { 9 | loadNotifications$ = createEffect(() => 10 | this.actions$.pipe( 11 | ofType(NotificationsActions.LOAD_ACTION), 12 | mergeMap(({page, pageSize}) => this.notificationsService.getAll(page, pageSize) 13 | .pipe( 14 | map(NotificationsActions.loaded), 15 | catchError(() => NotificationsActions.error) 16 | )) 17 | ) 18 | ); 19 | 20 | constructor(private actions$: Actions, 21 | private notificationsService: NotificationsService) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { AnonGuard } from './anon.guard'; 2 | import { AuthGuard } from './authGuard/auth.guard'; 3 | import { SharedModule } from '../shared/shared.module'; 4 | import { ApiService } from './apiService/api.service'; 5 | import { TokenService } from './token/token.service'; 6 | import { NgModule, Optional, SkipSelf } from '@angular/core'; 7 | import {HttpClientModule} from '@angular/common/http'; 8 | 9 | 10 | 11 | @NgModule({ 12 | imports: [ 13 | HttpClientModule, 14 | SharedModule 15 | ], 16 | exports: [ 17 | ], 18 | providers: [ 19 | TokenService, 20 | ApiService, 21 | AuthGuard, 22 | AnonGuard 23 | ], 24 | declarations: [] 25 | }) 26 | export class CoreModule { 27 | constructor( @Optional() @SkipSelf() private core: CoreModule) { 28 | if (core) { 29 | throw new Error( 30 | 'CoreModule was already imported. It cannot be imported twice' 31 | ); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/components/login/login.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | .header{ 3 | height: 70vh; 4 | h4 { 5 | margin:2rem 0; 6 | } 7 | .logo { 8 | background: #fbd844; 9 | border-radius: 50%; 10 | height:200px; 11 | width:200px; 12 | img { 13 | width:200px; 14 | } 15 | } 16 | } 17 | form { 18 | background-color:#fff; 19 | -webkit-box-shadow: 0px 0px 15px 5px rgba(204,204,204,1); 20 | -moz-box-shadow: 0px 0px 15px 5px rgba(204,204,204,1); 21 | box-shadow: 0px 0px 15px 5px rgba(204,204,204,1); 22 | border-radius: 0.5rem; 23 | width:450px; 24 | 25 | hr { 26 | width: calc(100% + 3rem); 27 | margin-left: -1.5rem; 28 | margin-right: -1.5rem; 29 | } 30 | 31 | label { 32 | color:rgba(0, 0, 0, 0.7); 33 | } 34 | 35 | 36 | } 37 | } -------------------------------------------------------------------------------- /src/app/table/table.model.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core' 2 | import { Observable } from 'rxjs'; 3 | 4 | export interface TableColumn { 5 | name: string; 6 | canBeSorted?: boolean; 7 | propertyName?: string; 8 | dataType?: 'STRING' | 'DATE'; 9 | } 10 | 11 | export type TableColumnTranslated = Omit & { name: Observable } 12 | 13 | export enum SortDirection { 14 | ASC, 15 | DESC 16 | } 17 | 18 | export interface SortedColumnEvent { 19 | col: TableColumn; 20 | sortDirection: SortDirection; 21 | } 22 | 23 | export interface SelectedZoneEvent { 24 | type: SelectedZoneEventTypes; 25 | selectedRowIds: string[]; 26 | } 27 | 28 | export enum SelectedZoneEventTypes { 29 | DELETE, 30 | NOTIFCATION 31 | } 32 | 33 | export const SELECTED_ZONE_EVENTS = new InjectionToken('SELECTED_ZONE_EVENTS', { 34 | providedIn: 'root', 35 | factory: () => SelectedZoneEventTypes, 36 | }); 37 | 38 | export const SORT_DIRECTION = new InjectionToken('SORT_DIRECTION', { 39 | providedIn: 'root', 40 | factory: () => SortDirection, 41 | }); 42 | -------------------------------------------------------------------------------- /src/app/store/statistics/statistics.state.ts: -------------------------------------------------------------------------------- 1 | import { statisticsConfig, StatisticsStateConfig } from './statistics.config'; 2 | 3 | import { LabelValueModel } from '../../models/labelValue.model'; 4 | import {keyBy} from 'lodash'; 5 | 6 | export class StatisticsStateItem { 7 | key: string; 8 | method: string; 9 | 10 | header: string; 11 | 12 | page: number; 13 | pageSize: number; 14 | totalPages: number; 15 | totalItems: number; 16 | 17 | loading = false; 18 | error = false; 19 | values = [] as LabelValueModel[]; 20 | 21 | constructor(config?: StatisticsStateConfig) { 22 | if (config) { 23 | this.key = config.key; 24 | this.method = config.method; 25 | this.header = config.header; 26 | } 27 | } 28 | } 29 | export class StatisticsState { 30 | [key: string]: StatisticsStateItem 31 | } 32 | export let statisticsInitialState: StatisticsState = keyBy( 33 | statisticsConfig.map((config) => new StatisticsStateItem(config)), value => value.key); 34 | -------------------------------------------------------------------------------- /src/app/core/authGuard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { TokenService } from '../token/token.service'; 2 | import { Injectable } from '@angular/core'; 3 | import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot, UrlSegment } from '@angular/router'; 4 | import { Observable } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class AuthGuard implements CanActivate, CanLoad { 8 | 9 | private static authObservable: Observable; 10 | 11 | constructor(private tokenService: TokenService, private router: Router) { } 12 | 13 | canLoad(route: Route, segments: UrlSegment[]): boolean | Observable | Promise { 14 | return this.checkForLogin(); 15 | } 16 | 17 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 18 | return this.checkForLogin(); 19 | } 20 | 21 | private checkForLogin(): Promise | boolean { 22 | if (this.tokenService.token) { 23 | return true; 24 | } 25 | this.router.navigate(['/login']); 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/store/form/form.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | 3 | import { FormState } from './form.reducer' 4 | 5 | export const form = createFeatureSelector('form'); 6 | 7 | export const getFormItems = createSelector( 8 | form, 9 | (state: FormState) => state.items ? state.items : [], 10 | ); 11 | 12 | export const getFormItemsById = createSelector( 13 | getFormItems, 14 | formItems => formItems.reduce((acc, f) => (acc[f.id] = f, acc), {}) 15 | ); 16 | 17 | export const getFullyLoadedForms = createSelector( 18 | form, 19 | (state: FormState) => state.fullyLoaded 20 | ); 21 | 22 | export const getAllQuestionsGroupedByTabId = createSelector( 23 | getFullyLoadedForms, 24 | loadedForms => Object.keys(loadedForms).reduce( 25 | (tabs, crtTabId) => { 26 | const tabValue = loadedForms[+crtTabId]; 27 | 28 | return { 29 | ...tabs, 30 | [crtTabId]: tabValue.formSections.flatMap(formSection => formSection.questions).reduce((acc, q) => (acc[q.id] = q, acc), {}), 31 | }; 32 | }, 33 | {} 34 | ) 35 | ) -------------------------------------------------------------------------------- /src/app/answers/answers.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { AnswersRoutingModule } from './answers-routing.module'; 5 | import { AnswersComponent } from './answers/answers.component'; 6 | import { TableModule } from '../table/table.module'; 7 | import { SharedModule } from '../shared/shared.module'; 8 | import { FormsModule } from '@angular/forms'; 9 | 10 | import { AnswerDetailsComponent } from './answer-details/answer-details.component'; 11 | import { AnswerNotificationComponent } from './answer-notification/answer-notification.component'; 12 | import { AnswerQuestionsComponent } from './answer-questions/answer-questions.component'; 13 | import { NotesComponent } from './notes/notes.component' 14 | 15 | @NgModule({ 16 | declarations: [AnswersComponent, AnswerDetailsComponent, AnswerNotificationComponent, AnswerQuestionsComponent, NotesComponent], 17 | imports: [ 18 | CommonModule, 19 | AnswersRoutingModule, 20 | FormsModule, 21 | TableModule, 22 | SharedModule, 23 | ] 24 | }) 25 | export class AnswersModule { } 26 | -------------------------------------------------------------------------------- /src/app/shared/base-checkbox/base-checkbox.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, HostBinding, HostListener, Input, OnInit, Output } from '@angular/core'; 2 | 3 | export enum CHECKBOX_VARIANTS { 4 | FILLED, 5 | 6 | } 7 | 8 | @Component({ 9 | selector: 'app-base-checkbox', 10 | templateUrl: './base-checkbox.component.html', 11 | styleUrls: ['./base-checkbox.component.scss'], 12 | changeDetection: ChangeDetectionStrategy.OnPush 13 | }) 14 | export class BaseCheckboxComponent implements OnInit { 15 | @Input() name: any; 16 | @Input() forceCheck = false; 17 | 18 | @Input('is-transparent') 19 | @HostBinding('class.is-transparent') isTransparent = false; 20 | 21 | @Input('is-disabled') 22 | @HostBinding('class.is-disabled') isDisabled = false; 23 | 24 | @Output() checkboxChanged = new EventEmitter(); 25 | 26 | @HostListener('click', ['$event']) 27 | onHostClick (ev: Event) { 28 | return false; 29 | } 30 | 31 | constructor() { } 32 | 33 | ngOnInit(): void { 34 | } 35 | 36 | checkBoxChanged () { 37 | this.checkboxChanged.next() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-card/statistics-card.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | {{ item.header | translate }} 6 |

7 |
8 |
9 |
10 |
11 | {{ "LOADING" | translate }} 12 |
13 |
14 |
18 | 24 | 25 |
26 | 27 | 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /src/app/shared/pagination/pagination.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | $foreground-color: #A5A5A5; 4 | 5 | :host { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | padding: 1.5rem .8rem; 10 | flex-wrap: wrap; 11 | 12 | &.is-hidden { 13 | display: none; 14 | } 15 | } 16 | 17 | .c-pagination { 18 | &__buttons { 19 | display: flex; 20 | align-items: center; 21 | } 22 | 23 | &__button { 24 | margin-left: .45rem; 25 | height: 30px; 26 | width: 30px; 27 | color: $foreground-color; 28 | cursor: pointer; 29 | 30 | &:not(&--direction) { 31 | background-color: transparent;; 32 | } 33 | 34 | &--selected:not(&--direction) { 35 | background-color: $primary-purple; 36 | color: #fff; 37 | } 38 | 39 | // adding `[class]` to have the same specificity 40 | // as `:not(.class)` from above 41 | &--disabled[class] { 42 | background-color: lighten($foreground-color, 20%); 43 | cursor: not-allowed; 44 | } 45 | } 46 | 47 | &__stats { 48 | color: $foreground-color; 49 | font-size: 1rem; 50 | } 51 | } -------------------------------------------------------------------------------- /src/app/components/answer/answer-form-list/answer-form-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Note } from '../../../models/note.model'; 2 | import { BaseQuestion } from '../../../models/base.question.model'; 3 | import { CompletedQuestion } from '../../../models/completed.question.model'; 4 | import { Component, Input } from '@angular/core'; 5 | import { find } from 'lodash'; 6 | import { Form } from '../../../models/form.model'; 7 | 8 | @Component({ 9 | selector: 'app-answer-form-list', 10 | templateUrl: './answer-form-list.component.html', 11 | styleUrls: ['./answer-form-list.component.scss'], 12 | }) 13 | export class AnswerFormListComponent { 14 | @Input() 15 | form: Form; 16 | 17 | @Input() 18 | completedQuestions: CompletedQuestion[]; 19 | 20 | @Input() 21 | notes: Note[]; 22 | 23 | answersForQuestion(question: BaseQuestion) { 24 | return find(this.completedQuestions, (value) => value.id === question.id); 25 | } 26 | notesForQuestion(question: BaseQuestion) { 27 | if (!this.notes || !this.notes.length) { 28 | return undefined; 29 | } 30 | return this.notes.filter((note) => note.questionId === question.id); 31 | } 32 | 33 | constructor() {} 34 | } 35 | -------------------------------------------------------------------------------- /src/app/components/forms/section/section.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../variables"; 2 | .section-create { 3 | margin-top:20px; // this matches that margin on the header-row bottom 4 | .section-details { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .add-question-button { 10 | color: $primary; 11 | cursor: pointer; 12 | } 13 | 14 | .form-labeled-field { 15 | margin-bottom: 10px; 16 | } 17 | 18 | .header-row { 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: space-between; 22 | margin-bottom: 20px; 23 | 24 | .header-text { 25 | margin: auto 0; 26 | } 27 | } 28 | 29 | .question-list { 30 | display: flex; 31 | flex-direction: column; 32 | 33 | .question-line { 34 | display: flex; 35 | flex-direction: row; 36 | 37 | .question { 38 | flex-grow: 1; 39 | } 40 | } 41 | } 42 | 43 | .icon-holder { 44 | /* display: flex; 45 | flex-direction: column; 46 | justify-content: space-around; */ 47 | 48 | cursor: pointer; 49 | 50 | margin: 12px 4px; 51 | } 52 | 53 | .icon-reorder { 54 | cursor: move; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/components/observers/base-observer-crud.component.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from '../../models/observer.model'; 2 | import { Input, Output, EventEmitter, Directive } from '@angular/core'; 3 | 4 | @Directive() 5 | export class BaseObserverCrudComponent { 6 | @Input() observer: Observer; 7 | @Input() enableEdit = false; 8 | @Input() selectionEnabled = false; 9 | 10 | @Output() onSelect: EventEmitter> = new EventEmitter(); 11 | @Output() onDelete: EventEmitter = new EventEmitter(); 12 | @Output() onResetPassword: EventEmitter = new EventEmitter(); 13 | 14 | toggleSelectedState() { 15 | if (this.selectionEnabled) { 16 | this.observer.isSelected = !this.observer.isSelected; 17 | this.onSelect.emit({ id: this.observer.id, isSelected: this.observer.isSelected }); 18 | } 19 | } 20 | 21 | 22 | deleteObserver() { 23 | if (confirm(`Are you sure to delete ${this.observer.name}`)) { 24 | this.onDelete.emit(this.observer); 25 | } 26 | } 27 | 28 | openResetPasswordModal(): void { 29 | this.onResetPassword.emit(this.observer); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/models/observer.model.ts: -------------------------------------------------------------------------------- 1 | export class Observer { 2 | id: string; 3 | name: string; 4 | ngo: string; 5 | phone: string; 6 | pin = ''; 7 | sendSMS = false; 8 | isSelected: boolean; 9 | deviceRegisterDate: string; 10 | lastSeen: string; 11 | numberOfNotes: number; 12 | numberOfPollingStations: number; 13 | 14 | 15 | constructor(observerResponse: any) { 16 | this.id = observerResponse.id ? observerResponse.id : ''; 17 | this.name = observerResponse.name ? observerResponse.name : ''; 18 | this.ngo = observerResponse.ngo ? observerResponse.ngo : ''; 19 | this.phone = observerResponse.phone ? observerResponse.phone : ''; 20 | this.sendSMS = observerResponse.sendSMS ? observerResponse.sendSMS : false; 21 | this.isSelected = observerResponse.isSelected ? observerResponse.isSelected : false; 22 | this.deviceRegisterDate = observerResponse.deviceRegisterDate ? observerResponse.deviceRegisterDate : null; 23 | this.numberOfNotes = observerResponse.numberOfNotes ? observerResponse.numberOfNotes : 0; 24 | this.numberOfPollingStations = observerResponse.numberOfPollingStations ? observerResponse.numberOfPollingStations : 0; 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/app/components/answer/answers-list/answer-list.component.html: -------------------------------------------------------------------------------- 1 | 31 |
32 | 33 |
34 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-details/statistics-details.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import {mergeMap, map} from 'rxjs/operators'; 3 | import { Store } from '@ngrx/store'; 4 | import { AppState } from '../../../store/store.module'; 5 | import { StatisticsStateItem } from '../../../store/statistics/statistics.state'; 6 | import { Component, Input, OnDestroy, OnInit } from '@angular/core'; 7 | import { Subscription } from 'rxjs'; 8 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 9 | 10 | @Component({ 11 | selector: 'app-statistics-details', 12 | templateUrl: './statistics-details.component.html', 13 | styleUrls: ['./statistics-details.component.scss'] 14 | }) 15 | export class StatisticsDetailsComponent implements OnInit, OnDestroy { 16 | state: StatisticsStateItem; 17 | subs: Subscription[]; 18 | 19 | @Input() item: StatisticsStateItem; 20 | 21 | constructor(private store: Store, public activeModal: NgbActiveModal) { } 22 | 23 | 24 | ngOnInit() { 25 | this.subs = [ this.store.select(s => s.statistics).pipe(map(s =>s[this.item.key])) 26 | .subscribe(s => this.state = s)]; 27 | 28 | } 29 | ngOnDestroy() { 30 | this.subs.map(sub => sub.unsubscribe()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-profile/observer-profile.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../variables'; 2 | 3 | $label-text-color: $dim-gray; 4 | $border-color: $border-color-gray; 5 | 6 | .c-profile { 7 | &__back-icon { 8 | transform: scale(1.4); 9 | } 10 | 11 | &__title { 12 | color: $darker-gray; 13 | } 14 | 15 | .card &__form-body { 16 | width: 60%; 17 | display: grid; 18 | grid-auto-flow: row; 19 | grid-template-columns: 1fr 1fr; 20 | column-gap: 5rem; 21 | row-gap: 1.2rem; 22 | 23 | input { 24 | padding: 1.4rem 0.7rem; 25 | border-radius: 6px; 26 | border: 1px solid $border-color; 27 | } 28 | } 29 | 30 | &__buttons { 31 | margin-top: 2rem; 32 | } 33 | 34 | &__button:not(:first-child) { 35 | margin-left: 1rem; 36 | } 37 | 38 | &__button--delete { 39 | color: $delete-color; 40 | } 41 | } 42 | 43 | .card { 44 | box-shadow: 4px 4px 30px rgba(0, 0, 0, 0.08); 45 | border-radius: 4px; 46 | border: none; 47 | } 48 | 49 | .form-group { 50 | label { 51 | color: $label-text-color; 52 | font-size: 1.15rem; 53 | } 54 | } 55 | 56 | @media all and (max-width: 600px) { 57 | .c-profile__form-body { 58 | width: 100%; 59 | } 60 | } -------------------------------------------------------------------------------- /src/app/shared/base-dropdown/base-dropdown.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, EventEmitter, Inject, Input, OnInit, Output, SimpleChanges, TemplateRef, ViewChildren } from '@angular/core'; 2 | import { BASE_BUTTON_VARIANTS, Variants } from '../base-button/base-button.component'; 3 | 4 | export interface DropdownConfigItem { 5 | name: string; 6 | isMain?: boolean; 7 | eventType: any; 8 | } 9 | 10 | @Component({ 11 | selector: 'app-base-dropdown', 12 | templateUrl: './base-dropdown.component.html', 13 | styleUrls: ['./base-dropdown.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class BaseDropdownComponent { 17 | @Output() dropdownEvent = new EventEmitter(); 18 | 19 | @Input() set configArr (cfg: DropdownConfigItem[]) { 20 | this.mainButton = cfg.find(i => i.isMain === true); 21 | this.dropdownItems = cfg.filter(({ isMain }) => !isMain); 22 | } 23 | 24 | @ContentChild(TemplateRef) btnContent: TemplateRef; 25 | 26 | dropdownItems: DropdownConfigItem[] = []; 27 | mainButton: DropdownConfigItem; 28 | 29 | constructor( 30 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants 31 | ) { } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/components/forms/form-create/form-create.component.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @import "../../../../variables"; 5 | .form-create { //? this isn't on the form 6 | .back-button { 7 | font-weight: bold; 8 | cursor: pointer; 9 | margin-bottom: 1.2em; 10 | } 11 | 12 | .add-section-button { 13 | color: $primary; 14 | cursor: pointer; 15 | font-weight: bold; 16 | } 17 | 18 | .form-labeled-field { 19 | margin-bottom: 10px; 20 | 21 | &-checkbox { 22 | @extend .form-labeled-field; 23 | 24 | display: flex; 25 | flex-direction: row; 26 | justify-content: space-between; 27 | 28 | .checkbox-label { 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: space-around; 32 | } 33 | 34 | .checkbox-field { 35 | flex-basis: 16px; 36 | } 37 | } 38 | } 39 | } 40 | 41 | 42 | .control-container { 43 | width: 100%; 44 | } 45 | 46 | 47 | .section-add { 48 | color: $primary; 49 | cursor: pointer; 50 | } 51 | 52 | .grow-1 { 53 | flex-grow: 1; 54 | } 55 | 56 | textarea { 57 | min-height: 124px; // aligns to 2 inputs in a column layout 58 | } 59 | 60 | //bootstrap override 61 | .invalid-feedback { 62 | display: block; 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics-card/statistics-card.component.ts: -------------------------------------------------------------------------------- 1 | import { StatisticsStateItem } from '../../../store/statistics/statistics.state'; 2 | import { Component, Input, OnInit } from '@angular/core'; 3 | import { StatisticsDetailsComponent } from '../statistics-details/statistics-details.component'; 4 | 5 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 6 | 7 | @Component({ 8 | selector: 'app-statistics-card', 9 | templateUrl: './statistics-card.component.html', 10 | styleUrls: ['./statistics-card.component.scss'], 11 | }) 12 | export class StatisticsCardComponent implements OnInit { 13 | @Input() item: StatisticsStateItem; 14 | 15 | @Input() sliceNumber: number; 16 | 17 | @Input() dialog: boolean; 18 | 19 | constructor(private modalService: NgbModal) {} 20 | 21 | ngOnInit() {} 22 | get itemValues() { 23 | if (!this.item.values) { 24 | return []; 25 | } 26 | if (this.sliceNumber) { 27 | return this.item.values.slice(0, this.sliceNumber); 28 | } else { 29 | return this.item.values; 30 | } 31 | } 32 | 33 | public openDetail(item: StatisticsStateItem): void { 34 | const modalRef = this.modalService.open(StatisticsDetailsComponent); 35 | modalRef.componentInstance.item = item; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/store/note/note.actions.ts: -------------------------------------------------------------------------------- 1 | import { Note } from '../../models/note.model'; 2 | import { Action, createAction, props } from '@ngrx/store'; 3 | import { actionType } from '../util'; 4 | export class NoteActionTypes { 5 | static readonly LOAD = actionType('[Note] Load'); 6 | static readonly LOAD_DONE = actionType('[Note] Load done'); 7 | } 8 | export class LoadNotesAction implements Action { 9 | readonly type = NoteActionTypes.LOAD; 10 | 11 | payload: { 12 | pollingStationId: number 13 | observerId: number 14 | }; 15 | constructor(pollingStationId: number, observerId: number) { 16 | this.payload = { 17 | pollingStationId, 18 | observerId 19 | }; 20 | } 21 | } 22 | export class LoadNotesDoneAction implements Action { 23 | readonly type = NoteActionTypes.LOAD_DONE; 24 | payload: (Omit & { attachmentsPaths: string[] })[]; 25 | constructor(note: (Omit & { attachmentsPaths: string[] })[]) { 26 | this.payload = note; 27 | } 28 | } 29 | export type NoteActions = LoadNotesAction | LoadNotesDoneAction; 30 | 31 | export const setLoadingStatusFromEffects = createAction( 32 | '[Note Effects] Set Loading Status From effects', 33 | props<{ isLoading: boolean }>() 34 | ); -------------------------------------------------------------------------------- /src/app/shared/icon-toggle-input/icon-toggle-input.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, forwardRef, Input} from '@angular/core'; 2 | import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; 3 | 4 | @Component({ 5 | selector: 'app-icon-toggle-input', 6 | templateUrl: './icon-toggle-input.component.html', 7 | styleUrls: ['./icon-toggle-input.component.scss'], 8 | providers: [ 9 | { 10 | provide: NG_VALUE_ACCESSOR, 11 | useExisting: forwardRef(() => IconToggleInputComponent), 12 | multi: true 13 | } 14 | ] 15 | }) 16 | export class IconToggleInputComponent implements ControlValueAccessor { 17 | 18 | @Input() value = false; 19 | 20 | @Input() enabledIcon: string; 21 | @Input() disabledIcon: string; 22 | @Input() enabledIconTooltip: string; 23 | @Input() disabledIconTooltip: string; 24 | 25 | onChange = (value: boolean) => {}; 26 | onTouched = () => {}; 27 | 28 | constructor() { } 29 | 30 | toggleValue() { 31 | this.value = !this.value; 32 | this.onChange(this.value); 33 | } 34 | 35 | registerOnChange(fn: (value: boolean) => void): void { 36 | this.onChange = fn; 37 | } 38 | 39 | registerOnTouched(fn: () => void): void { 40 | this.onTouched = fn; 41 | } 42 | 43 | writeValue(value: boolean): void { 44 | this.value = value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/shared/base-button/base-button.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../variables"; 2 | 3 | $base-foreground-color: $primary-gray; 4 | 5 | $bg-yellow: #FFD628; 6 | $bg-yellow-foreground: #464646; 7 | 8 | $bg-gray: #F2F2F2; 9 | 10 | :host { 11 | display: inline-block; 12 | } 13 | 14 | button { 15 | color: $base-foreground-color; 16 | font-weight: bold; 17 | border: 1px solid $base-gray; 18 | background: transparent; 19 | padding: 0.7rem 1.2rem; 20 | border-radius: 0.55rem; 21 | width: 100%; 22 | height: 100%; 23 | 24 | &[disabled] { 25 | cursor: not-allowed; 26 | } 27 | } 28 | 29 | :host.is-bg-yellow button { 30 | background: $bg-yellow; 31 | color: $bg-yellow-foreground; 32 | border: none; 33 | 34 | &[disabled] { 35 | background: lighten($bg-yellow, 10%); 36 | color: lighten($bg-yellow-foreground, 15%); 37 | } 38 | } 39 | 40 | :host.is-bg-gray button { 41 | background: $bg-gray; 42 | border: none; 43 | border-radius: 3px; 44 | padding: 0.5rem 1.1rem; 45 | } 46 | 47 | :host.is-all-transparent button { 48 | border: none; 49 | } 50 | 51 | :host.is-purple button { 52 | border-radius: 6px; 53 | border-color: $primary-purple; 54 | color: $primary-purple; 55 | font-weight: 500; 56 | font-size: 1.1rem; 57 | } 58 | 59 | :host.has-color-inherited button { 60 | color: inherit; 61 | } -------------------------------------------------------------------------------- /src/app/components/header/header.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../variables"; 2 | // the `.show` class is added when `ngbCollapse` is `true` 3 | // and removed when set to `false` 4 | 5 | // the value is not chosen randomly, `min-width: 992px` 6 | // is used in bootstrap/_navbar.scss, where the tabs are made visible 7 | @media (max-width: 992px) { 8 | .collapse:not(.show) { 9 | max-height: 0; 10 | opacity: 0; 11 | display: block; 12 | transition: max-height 0.2s; 13 | pointer-events: none; 14 | } 15 | 16 | .collapse.show { 17 | max-height: 50rem; 18 | opacity: 1; 19 | transition: max-height 0.2s, opacity 0.2s; 20 | pointer-events: all; 21 | } 22 | 23 | .nav-item { 24 | text-align: center; 25 | font-size: 1.2rem; 26 | } 27 | } 28 | 29 | .navbar { 30 | background-color: white; 31 | 32 | .nav-item { 33 | font-weight: $font-weight-medium; 34 | padding-left: 10px; 35 | padding-right: 10px; 36 | 37 | .active { 38 | color: $primary-purple; 39 | background-color: rgb(95, 40, 141, 10%); 40 | border-radius: 2px; 41 | } 42 | } 43 | 44 | .dropdown-toggle { 45 | color: $primary-gray; 46 | 47 | &::after{ 48 | color: $yellow; 49 | } 50 | } 51 | 52 | .user-icon{ 53 | background-image: '../../../assets/user-icon.svg'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/answers/notes/notes.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .c-notes-row, .c-notes-columns { 4 | display: grid; 5 | grid-template-columns: 1fr 2fr 3fr 0.5fr; 6 | 7 | @media all and (max-width: 700px) { 8 | grid-template-columns: 1fr 1fr 1fr 0.5fr; 9 | } 10 | } 11 | 12 | .c-notes-column:nth-child(2) { 13 | padding-left: 25%; 14 | } 15 | 16 | .c-notes-columns { 17 | padding-top: 0.7rem; 18 | padding-bottom: .5rem; 19 | color: $dim-gray; 20 | } 21 | 22 | .c-notes-rows { 23 | color: $readable-dark-gray; 24 | padding-bottom: 1rem; 25 | } 26 | 27 | .c-notes-row { 28 | height: 4.7rem; 29 | border: 1px solid $border-color-gray; 30 | border-left: none; 31 | border-right: none; 32 | cursor: pointer; 33 | 34 | &:hover { 35 | background-color: $selected-row-color; 36 | } 37 | 38 | &:not(:last-child) { 39 | border-bottom: none; 40 | } 41 | 42 | &__form-details { 43 | display: flex; 44 | justify-content: center; 45 | flex-direction: column; 46 | padding-left: 0.3rem; 47 | } 48 | 49 | &__note-content { 50 | padding: 1rem 0; 51 | overflow: hidden; 52 | } 53 | 54 | &__flagged-status { 55 | display: flex; 56 | align-items: center; 57 | padding-left: 30%; 58 | } 59 | } 60 | 61 | .c-notes-empty-rows { 62 | color: $readable-dark-gray; 63 | text-align: center; 64 | margin: 0; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/components/answer/answers-list/answer-list.component.ts: -------------------------------------------------------------------------------- 1 | import { AnswerState } from '../../../store/answer/answer.reducer'; 2 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 3 | @Component({ 4 | selector: 'app-answer-list', 5 | templateUrl: './answer-list.component.html', 6 | styleUrls: ['./answer-list.component.scss'], 7 | }) 8 | export class AnswerListComponent implements OnInit { 9 | @Input('answerState') 10 | state: AnswerState; 11 | 12 | @Output() 13 | pageChanged: EventEmitter = new EventEmitter(); 14 | 15 | @Output() 16 | reload: EventEmitter<{}> = new EventEmitter(); 17 | 18 | ngOnInit() {} 19 | 20 | retry() { 21 | this.reload.emit(); 22 | } 23 | answerLinkPrefix() { 24 | return this.state.urgent ? '/urgents/details' : '/answers/details'; 25 | } 26 | get answers() { 27 | const start = this.state.page * this.state.pageSize, 28 | end = start + this.state.pageSize; 29 | return this.state.threads.slice(start, end); 30 | } 31 | 32 | answerList() { 33 | const startPage = this.state.page - 1, 34 | pageSize = this.state.pageSize, 35 | startIndex = startPage * pageSize, 36 | endIndex = startIndex + pageSize; 37 | 38 | return this.state.threads.slice(startIndex, endIndex); 39 | } 40 | 41 | pageChangedEvent(event) { 42 | this.pageChanged.emit(event); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/components/statistics/statistics.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import {map} from 'rxjs/operators'; 3 | import { StatisticsStateItem } from '../../store/statistics/statistics.state'; 4 | import { AppState } from '../../store/store.module'; 5 | import {select, Store} from '@ngrx/store'; 6 | import { Subscription } from 'rxjs'; 7 | import { Component, OnDestroy, OnInit } from '@angular/core'; 8 | import {values} from 'lodash'; 9 | 10 | @Component({ 11 | selector: 'app-statistics', 12 | templateUrl: './statistics.component.html', 13 | styleUrls: ['./statistics.component.scss'] 14 | }) 15 | export class StatisticsComponent implements OnInit, OnDestroy { 16 | 17 | statisticsState: StatisticsStateItem[]; 18 | sub: Subscription; 19 | 20 | anyStatistics = false; 21 | 22 | constructor(private store: Store) { } 23 | 24 | 25 | canShowItem(item: StatisticsStateItem) { 26 | return item && !item.error && !item.loading && item.values && item.values.length; 27 | } 28 | 29 | ngOnInit() { 30 | this.sub = this.store 31 | .pipe( 32 | select(state => state.statistics), 33 | map(state => values(state)), 34 | map(s => s.filter(v => !v.error && !v.loading)), ) 35 | .subscribe(s => { 36 | this.statisticsState = s; 37 | this.anyStatistics = !!s.length; 38 | }); 39 | } 40 | ngOnDestroy() { 41 | this.sub.unsubscribe(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/components/forms/option/option.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 12 | 13 |
14 | 15 |
16 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |
28 |
29 | 30 |
31 | 32 | -------------------------------------------------------------------------------- /src/app/components/forms/section/section.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, EventEmitter, Input, Output, } from '@angular/core'; 2 | import {FormQuestion} from '../../../models/form.question.model'; 3 | import {CdkDragDrop} from '@angular/cdk/drag-drop'; 4 | import {FormArray, FormBuilder, FormGroup} from '@angular/forms'; 5 | import {moveItemInFormArray} from '../../utils'; 6 | import {initQuestionFormGroup} from '../form-groups-builder'; 7 | 8 | @Component({ 9 | selector: 'app-section', 10 | templateUrl: './section.component.html', 11 | styleUrls: ['./section.component.scss'] 12 | }) 13 | export class SectionComponent { 14 | @Input() sectionFormGroup: FormGroup; 15 | 16 | @Output() sectionDeleteEventEmitter = new EventEmitter(); 17 | 18 | constructor(private formBuilder: FormBuilder) { 19 | } 20 | 21 | addQuestion() { 22 | this.questionsArray.push(initQuestionFormGroup(this.formBuilder)); 23 | } 24 | 25 | get questionsArray(): FormArray { 26 | return this.sectionFormGroup.get('questions') as FormArray; 27 | } 28 | 29 | get questionFormGroupsArray(): FormGroup[] { 30 | return this.questionsArray.controls as FormGroup[]; 31 | } 32 | 33 | onQuestionDelete(index: number) { 34 | this.questionsArray.removeAt(index); 35 | } 36 | 37 | onReorder(event: CdkDragDrop) { 38 | moveItemInFormArray(this.questionsArray, event.previousIndex, event.currentIndex); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/components/forms/forms.component.scss: -------------------------------------------------------------------------------- 1 | @import "../../../variables"; 2 | @import "node_modules/bootstrap/scss/functions"; 3 | @import "node_modules/bootstrap/scss/variables"; 4 | @import "node_modules/bootstrap/scss/mixins/_breakpoints"; 5 | 6 | @include media-breakpoint-up(xl) { 7 | 8 | } 9 | 10 | @include media-breakpoint-up(lg) { 11 | 12 | } 13 | 14 | @include media-breakpoint-up(md) { 15 | 16 | } 17 | 18 | .nav > li { 19 | position: relative; 20 | } 21 | 22 | .nav .nav-item a .active { 23 | color: $primary; 24 | } 25 | 26 | .nav .nav-item a { 27 | color: $secondary; 28 | } 29 | 30 | .nav .nav-item .active::after { 31 | content: " "; 32 | position: absolute; 33 | bottom: 0; 34 | left: 0; 35 | right: 0; 36 | background-color: $primary; 37 | height: 2px; 38 | transition: all 250ms ease 0s; 39 | } 40 | 41 | .w-15 { 42 | width: 15%; 43 | } 44 | 45 | .no-wrap { 46 | white-space: nowrap; 47 | } 48 | 49 | .centered-cell { 50 | text-align: center; 51 | vertical-align: middle; 52 | } 53 | 54 | .cdk-drop-list-dragging .cdk-drag { 55 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 56 | } 57 | 58 | .cdk-drag-animating { 59 | transition: transform 300ms cubic-bezier(0, 0, 0.2, 1); 60 | } 61 | 62 | .cdk-drag-placeholder { 63 | opacity: 0; 64 | } 65 | 66 | .cdk-drag-preview { 67 | box-sizing: border-box; 68 | border-radius: 4px; 69 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); 70 | } 71 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "./styles/toastr.scss"; 3 | 4 | @import "../node_modules/bootstrap/scss/bootstrap.scss"; 5 | 6 | html, body { 7 | height: 100%; 8 | } 9 | 10 | body { 11 | background-color: $bg-global; 12 | // font-family: 'Poppins', sans-serif; 13 | } 14 | 15 | .cursor-pointer { 16 | cursor: pointer; 17 | } 18 | 19 | .cursor-move{ 20 | cursor: move; 21 | } 22 | 23 | .custom-tooltip { 24 | .tooltip-inner { 25 | background-color: white; 26 | color: black; 27 | } 28 | .arrow::before { 29 | border-bottom-color: white; 30 | } 31 | } 32 | 33 | .h-vertical-line { 34 | width: 1px; 35 | height: 25px; 36 | background: $muted-gray; 37 | display: inline-block; 38 | margin: 0 .9rem; 39 | vertical-align: top; 40 | } 41 | 42 | .h-is-muted { 43 | color: $muted-gray; 44 | } 45 | 46 | .is-visually-hidden { 47 | border: 0; 48 | clip: rect(0 0 0 0); 49 | height: auto; 50 | margin: 0; 51 | overflow: hidden; 52 | padding: 0; 53 | width: 1px; 54 | white-space: nowrap; 55 | position: fixed; 56 | } 57 | 58 | .is-base-cell { 59 | display: inline-flex; 60 | width: 1.8rem; 61 | height: 1.8rem; 62 | background: $light-gray; 63 | justify-content: center; 64 | align-items: center; 65 | border-radius: 4px; 66 | } 67 | 68 | button { 69 | &.is-base-cell { 70 | border: none; 71 | 72 | &:focus:active { 73 | border: none; 74 | outline: none; 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/app/components/answer/answer-extra-questions/answer-extra-questions.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{ "ARRIVAL_TIME" | translate }}: 4 | {{ answerExtra.observerArrivalTime | date: "shortTime" }} 5 | 6 | 7 | {{ "DEPARTURE_TIME" | translate }}: 8 | {{ answerExtra.observerLeaveTime | date: "shortTime" }} 9 | 10 |
11 | 12 | 13 | 14 | 17 | 23 | 24 | 27 | 33 | 34 | 37 | 43 |
44 | -------------------------------------------------------------------------------- /src/app/shared/base-checkbox/base-checkbox.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | $filled-unchecked-bg: $light-gray; 4 | $filled-checked-bg: $primary-purple; 5 | 6 | $border-color: $base-gray; 7 | 8 | label { 9 | position: relative; 10 | cursor: pointer; 11 | } 12 | 13 | label::after, 14 | label::before { 15 | position: absolute; 16 | content: ''; 17 | } 18 | 19 | label::before { 20 | width: 20px; 21 | height: 20px; 22 | border-radius: 4px; 23 | background-color: $filled-unchecked-bg; 24 | } 25 | 26 | input:checked + label::before { 27 | background-color: $filled-checked-bg; 28 | } 29 | 30 | input:checked + label::after { 31 | height: 11px; 32 | width: 4px; 33 | border-bottom: 1.5px solid #fff; 34 | border-right: 1.5px solid #fff; 35 | transform: translate(200%, 30%) rotate(35deg); 36 | } 37 | 38 | :host.is-transparent { 39 | label::before { 40 | width: 20px; 41 | height: 20px; 42 | border-radius: 4px; 43 | background-color: #fff; 44 | border: 1px solid $primary-purple; 45 | } 46 | 47 | input:checked + label::before { 48 | background-color: #fff; 49 | } 50 | 51 | input:checked + label::after { 52 | height: 16px; 53 | width: 6px; 54 | border-bottom: 1.5px solid $primary-purple; 55 | border-right: 1.5px solid $primary-purple; 56 | transform: translate(200%, -20%) rotate(48deg); 57 | box-shadow: 1px 1p 1px 1px red; 58 | box-shadow: 6px -9px 0px 0px #fff, inset -3px -4px 0px 0px #fff; 59 | } 60 | } 61 | 62 | :host.is-disabled { 63 | label { 64 | cursor: not-allowed; 65 | } 66 | } -------------------------------------------------------------------------------- /src/app/components/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 11 |

Welcome to VoteMonitor

12 |
13 |
{{ 'LOGIN' | translate }}:
14 |
15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |

24 | {{'CREDENTIALS_INVALID' | translate}} 25 |

26 | 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /src/app/components/forms/question/question.component.scss: -------------------------------------------------------------------------------- 1 | 2 | @import "../../../../variables"; 3 | span { 4 | &.rotate { 5 | img { 6 | transform: rotate(270deg) 7 | } 8 | } 9 | } 10 | 11 | .add-option-button { 12 | color: $primary; 13 | cursor: pointer; 14 | } 15 | 16 | .options-list { 17 | display: flex; 18 | flex-direction: column; 19 | 20 | .option-line { 21 | display: flex; 22 | flex-direction: row; 23 | 24 | .option { 25 | flex-grow: 1; 26 | } 27 | } 28 | 29 | } 30 | 31 | 32 | .question-component { 33 | 34 | display: flex; 35 | flex-direction: row; 36 | 37 | 38 | 39 | .options-panel { 40 | background-color: $border-color-gray; 41 | padding: 8px; 42 | 43 | .options-list { 44 | display: flex; 45 | flex-direction: column; 46 | 47 | .option-line { 48 | display: flex; 49 | flex-direction: row; 50 | 51 | .option { 52 | flex-grow: 1; 53 | } 54 | } 55 | 56 | } 57 | } 58 | 59 | .expandable-container { 60 | display: flex; 61 | flex-direction: column; 62 | 63 | flex-grow: 1; 64 | 65 | padding: 4px; 66 | } 67 | 68 | .main-fields-container { 69 | display: flex; 70 | justify-content: space-evenly; 71 | flex-direction: row; 72 | 73 | border: 1px solid $border-color-gray; 74 | } 75 | 76 | .icon-holder { 77 | display: flex; 78 | flex-direction: column; 79 | justify-content: space-around; 80 | 81 | cursor: pointer; 82 | } 83 | 84 | /* .field { 85 | margin: 16px auto; 86 | } */ 87 | } 88 | -------------------------------------------------------------------------------- /src/app/shared/base-dropdown/base-dropdown.component.html: -------------------------------------------------------------------------------- 1 | 11 | 15 | 16 | 17 | 18 |
19 | 20 | 29 | 30 | 43 |
-------------------------------------------------------------------------------- /src/app/store/statistics/statistics.config.ts: -------------------------------------------------------------------------------- 1 | export interface StatisticsStateConfig { 2 | key: string; 3 | method: string; 4 | header: string; 5 | } 6 | export let statisticsConfig = [ 7 | { 8 | key: 'numar-observatori', 9 | method: 'observerNumber', 10 | header: 'Top municipalities by number of observers.', 11 | }, 12 | { 13 | key: 'sesizari-judete', 14 | method: 'countiesIrregularities', 15 | header: 'Top municipalities by filled-in forms with a flagged answer (all forms)', 16 | }, 17 | { 18 | key: 'sesizari-sectii', 19 | method: 'pollingStationIrregularities', 20 | header: 'Top polling stations by filled-in forms with a flagged answer (all forms)', 21 | }, 22 | { 23 | key: 'sesizari-deschidere-judete', 24 | method: 'countiesOpeningIrregularities', 25 | header: 'Top municipalities by filled-in forms with a flagged answer and form code = "A" (opening)', 26 | }, 27 | { 28 | key: 'sesizari-deschidere-sectii', 29 | method: 'pollingStationOpeningIrregularities', 30 | header: 'Top polling stations by filled-in forms with a flagged answer and form code = "A" (opening)', 31 | }, 32 | { 33 | key: 'sesizari-numarare-judete', 34 | method: 'countiesByCountingIrregularities', 35 | header: 'Top municipalities by filled-in forms with a flagged answer checked and form code = "C" (closing)', 36 | }, 37 | { 38 | key: 'sesizari-numarare-sectii', 39 | method: 'pollingStationsByCountingIrregularities', 40 | header: 'Top polling stations by filled-in forms with a flagged answer checked and form code = "C" (closing)', 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/app/components/forms/form-groups-builder.ts: -------------------------------------------------------------------------------- 1 | import { FormBuilder, Validators } from '@angular/forms'; 2 | 3 | 4 | export function initFormFormGroup(formBuilder: FormBuilder) { 5 | return formBuilder.group({ 6 | id: formBuilder.control(0), 7 | orderNumber: formBuilder.control(0), 8 | description: formBuilder.control(null, Validators.required), 9 | code: formBuilder.control(null, Validators.required), 10 | diaspora: formBuilder.control(false), 11 | formSections: formBuilder.array([], Validators.required) 12 | }); 13 | } 14 | 15 | export function initSectionFormGroup(formBuilder: FormBuilder) { 16 | return formBuilder.group({ 17 | id: formBuilder.control(0), 18 | orderNumber: formBuilder.control(0), 19 | questions: formBuilder.array([]), 20 | description: formBuilder.control(''), 21 | code: formBuilder.control('') 22 | }); 23 | } 24 | 25 | export function initQuestionFormGroup(formBuilder: FormBuilder) { 26 | return formBuilder.group({ 27 | id: formBuilder.control(0), 28 | orderNumber: formBuilder.control(0), 29 | optionsToQuestions: formBuilder.array([]), 30 | text: formBuilder.control(''), 31 | code: formBuilder.control(''), 32 | questionType: formBuilder.control(0) 33 | }); 34 | } 35 | 36 | export function initOptionFormGroup(formBuilder: FormBuilder) { 37 | return formBuilder.group({ 38 | id: formBuilder.control(0), 39 | orderNumber: formBuilder.control(0), 40 | optionId: formBuilder.control(0), 41 | text: formBuilder.control(''), 42 | isFreeText: formBuilder.control(false), 43 | flagged: formBuilder.control(false) 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/store/form/form.reducer.ts: -------------------------------------------------------------------------------- 1 | import {FormActions, FormActionTypes} from './form.actions'; 2 | import {Form} from '../../models/form.model'; 3 | import {FormDetails} from '../../models/form.info.model'; 4 | import {cloneDeep} from 'lodash'; 5 | 6 | export class FormState { 7 | items: FormDetails[]; 8 | fullyLoaded: { 9 | [key: number]: Form; 10 | }; 11 | } 12 | const formsInitialState: FormState = { 13 | items: undefined, 14 | fullyLoaded: {} 15 | }; 16 | export function formReducer(state = formsInitialState, $action: FormActions) { 17 | switch ($action.type) { 18 | case FormActionTypes.LOAD_ALL_FORMS_META_COMPLETE: 19 | return { 20 | ...state, 21 | items: $action.payload 22 | }; 23 | case FormActionTypes.CLEAR: 24 | return { 25 | fullyLoaded: [], 26 | items: [] 27 | }; 28 | case FormActionTypes.LOAD_ONE_FORM_FULLY_COMPLETE: 29 | const fullyLoaded = state.fullyLoaded; 30 | const loadedForm = $action.payload; 31 | const formDetails = state.items.find(f => f.id === loadedForm.id); 32 | 33 | const mutableLoadedForm: Form = cloneDeep(loadedForm); 34 | mutableLoadedForm.inheritMetaData(formDetails); 35 | 36 | const allFullyLoadedForms = {...fullyLoaded}; 37 | allFullyLoadedForms[mutableLoadedForm.id] = mutableLoadedForm; 38 | return { 39 | ...state, 40 | fullyLoaded: allFullyLoadedForms 41 | }; 42 | default: 43 | return state; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/components/answer/categorical-question/categorical-question.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{ question.code }}: {{ question.text }} ? 4 |

5 |
6 |
7 |
11 |
12 | 17 | 20 |
21 | 22 | 29 |
30 |
31 |
32 | 41 |
42 | 43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /src/app/components/answer/answer-details/answer-details.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |
11 | 15 | 16 | 17 | 39 |
40 |
41 |
42 | {{ "NOTHING_SELECTED" | translate }} 43 |
44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /src/app/components/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'rxjs'; 2 | import { TokenService } from '../../core/token/token.service'; 3 | import { Router } from '@angular/router'; 4 | import { ApiService } from '../../core/apiService/api.service'; 5 | import { Component, OnInit } from '@angular/core'; 6 | import { Location } from '@angular/common'; 7 | import { environment } from 'src/environments/environment'; 8 | 9 | @Component({ 10 | templateUrl: './login.component.html', 11 | styleUrls: ['./login.component.scss'], 12 | }) 13 | export class LoginComponent implements OnInit { 14 | private baseUrl: string; 15 | constructor( 16 | private http: ApiService, 17 | private router: Router, 18 | private tokenService: TokenService 19 | ) { 20 | this.baseUrl = environment.apiUrl; 21 | } 22 | 23 | user: string; 24 | password: string; 25 | 26 | invalid: boolean; 27 | 28 | loginSubscription: Subscription; 29 | 30 | tryLogin() { 31 | if (this.loginSubscription) { 32 | this.loginSubscription.unsubscribe(); 33 | } 34 | const authUrl: string = Location.joinWithSlash( 35 | this.baseUrl, 36 | '/api/v2/access/authorize' 37 | ); 38 | this.loginSubscription = this.http 39 | .post<{ access_token: string; expires_in: number }>(authUrl, { 40 | user: this.user, 41 | password: this.password, 42 | }) 43 | .subscribe( 44 | (res) => { 45 | this.tokenService.token = res.access_token; 46 | this.router.navigate(['/answers']); 47 | }, 48 | () => { 49 | this.invalid = true; 50 | } 51 | ); 52 | } 53 | 54 | ngOnInit() {} 55 | } 56 | -------------------------------------------------------------------------------- /src/app/answers/notes/notes.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Output, TemplateRef, ViewChild } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { Store } from '@ngrx/store'; 4 | import { Subject } from 'rxjs'; 5 | import { Note } from 'src/app/models/note.model'; 6 | import { BASE_BUTTON_VARIANTS, Variants } from 'src/app/shared/base-button/base-button.component'; 7 | import { LoadNotesAction } from 'src/app/store/note/note.actions'; 8 | import { DisplayedNote } from '../answers.model'; 9 | 10 | @Component({ 11 | selector: 'app-notes', 12 | templateUrl: './notes.component.html', 13 | styleUrls: ['./notes.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class NotesComponent implements OnInit { 17 | @Input() notes: DisplayedNote[] = []; 18 | 19 | @Output() questionLinkClicked = new EventEmitter(); 20 | @Output() showNoteInModal = new EventEmitter(); 21 | 22 | @ViewChild('modalContent') modalContent: TemplateRef; 23 | 24 | columns = [ 25 | { name: 'Form&Question', }, 26 | { name: 'Red Flag', propertyName: 'isQuestionFlagged', }, 27 | { name: 'Note', propertyName: 'text' }, 28 | ]; 29 | 30 | constructor ( 31 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants 32 | ) { } 33 | 34 | ngOnInit(): void { 35 | } 36 | 37 | onQuestionLinkClicked (event: Event, note: DisplayedNote) { 38 | event.stopPropagation(); 39 | this.questionLinkClicked.emit(note); 40 | } 41 | 42 | onRowClicked (note: DisplayedNote) { 43 | this.showNoteInModal.emit(note); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/routing/guards/load-statistics.guard.ts: -------------------------------------------------------------------------------- 1 | 2 | import {of as observableOf, from as observableFrom} from 'rxjs'; 3 | 4 | import {concatMap, map, take} from 'rxjs/operators'; 5 | import { StatisticsStateItem } from '../../store/statistics/statistics.state'; 6 | import { LoadStatisticAction } from '../../store/statistics/statistics.actions'; 7 | import { AppState } from '../../store/store.module'; 8 | import {select, Store} from '@ngrx/store'; 9 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; 10 | import { Injectable } from '@angular/core'; 11 | import { values } from 'lodash'; 12 | 13 | @Injectable() 14 | export class LoadStatisticsGuard implements CanActivate { 15 | constructor(private store: Store) { 16 | 17 | } 18 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { 19 | if (route.params['key']) { 20 | this.store.pipe( 21 | select(s => s.statistics[route.params['key']]), 22 | take(1), 23 | map(s => new LoadStatisticAction(s.key, 1, 20, true)), ) 24 | .subscribe(a => this.store.dispatch(a)); 25 | } else { 26 | this.store.pipe( 27 | select(s => s.statistics), 28 | take(1), 29 | // .do(console.log) 30 | map(s => values(s)), 31 | concatMap(s => observableFrom(s)), 32 | // .do(console.log) 33 | map((i: StatisticsStateItem) => new LoadStatisticAction(i.key, 1, 5, true)), ) 34 | .subscribe(a => this.store.dispatch(a)); 35 | } 36 | return observableOf(true); 37 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '@angular/router'; 2 | import { TokenService } from '../../core/token/token.service'; 3 | import { environment } from 'src/environments/environment'; 4 | import { Component, OnInit } from '@angular/core'; 5 | import { Store } from '@ngrx/store'; 6 | import { logout } from 'src/app/store/user/user.actions'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | @Component({ 10 | selector: 'app-header', 11 | templateUrl: './header.component.html', 12 | styleUrls: ['./header.component.scss'], 13 | }) 14 | export class HeaderComponent implements OnInit { 15 | isHamburgerClicked = false; 16 | 17 | constructor( 18 | private tokenService: TokenService, 19 | private translateService: TranslateService, 20 | private router: Router, 21 | private store: Store, 22 | ) {} 23 | 24 | get observerGuideUrl() { 25 | return environment.observerGuideUrl; 26 | } 27 | 28 | get userName() { 29 | return this.tokenService.userName; 30 | } 31 | 32 | ngOnInit() {} 33 | 34 | isLoggedIn() { 35 | return this.tokenService.isloggedIn(); 36 | } 37 | 38 | getAvailableLanguages() { 39 | return this.translateService.getLangs(); 40 | } 41 | 42 | setLanguage(language: string) { 43 | localStorage.setItem('language', language); 44 | this.translateService.use(language); 45 | this.translateService.setDefaultLang(language); 46 | const prev = this.router.url; 47 | this.router.navigate(['/']).then(_ => { 48 | this.router.navigate([prev]); 49 | }); 50 | } 51 | 52 | logout() { 53 | this.tokenService.token = undefined; 54 | this.store.dispatch(logout()); 55 | this.router.navigate(['/login']); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/store/note/note.reducer.ts: -------------------------------------------------------------------------------- 1 | import { NoteActions, NoteActionTypes, setLoadingStatusFromEffects } from './note.actions'; 2 | import { Note } from '../../models/note.model'; 3 | export class NoteState { 4 | notes: Note[]; 5 | loading = false; 6 | error = false; 7 | observerId: number = undefined; 8 | pollingStationId: number = undefined; 9 | } 10 | export let noteInitialState = new NoteState(); 11 | 12 | const baseUrl = url => url.indexOf('?') === -1 ? url : url.slice(0, url.indexOf('?')); 13 | function attachmentIsImage(path: string) { 14 | path = baseUrl(path); 15 | const isImage = path.toLowerCase().match(/\.(jpg|jpeg|png|gif)$/i); 16 | 17 | return isImage; 18 | } 19 | 20 | export function noteReducer(state = noteInitialState, action: NoteActions | any) { 21 | switch (action.type) { 22 | case NoteActionTypes.LOAD: 23 | return { 24 | ...state, 25 | // loading: true, 26 | error: false, 27 | observerId: action.payload.observerId, 28 | pollingStationId: action.payload.pollingStationId 29 | }; 30 | case NoteActionTypes.LOAD_DONE: 31 | return Object.assign({}, state, { 32 | notes: action.payload.map( 33 | n => ({ ...n, attachmentsPaths: n.attachmentsPaths.map(a => ({ src: a, isImage: attachmentIsImage(a) })) }) 34 | ), 35 | // loading: false, 36 | error: false 37 | }); 38 | case setLoadingStatusFromEffects.type: 39 | return { 40 | ...state, 41 | loading: action.isLoading 42 | } 43 | default: 44 | return state; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/shared/base-button/base-button.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, HostBinding, InjectionToken, Input, OnInit, ViewEncapsulation } from '@angular/core'; 2 | 3 | export const BASE_BUTTON_VARIANTS = new InjectionToken('BASE_BUTTON_VARIANTS', { 4 | providedIn: 'root', 5 | factory: () => Variants 6 | }); 7 | 8 | export enum Variants { 9 | DEFAULT, 10 | BGYELLOW, 11 | BGGRAY, 12 | ALLTRANSPARENT, 13 | PURPLE, 14 | }; 15 | 16 | @Component({ 17 | selector: 'app-base-button', 18 | templateUrl: './base-button.component.html', 19 | styleUrls: ['./base-button.component.scss'], 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | }) 22 | export class BaseButtonComponent implements OnInit { 23 | 24 | @HostBinding('class.is-bg-yellow') isBgYellow = false; 25 | @HostBinding('class.is-bg-gray') isBgGray = false; 26 | @HostBinding('class.is-all-transparent') isAllTransparent = false; 27 | @HostBinding('class.is-purple') isPurple = false; 28 | 29 | @Input('custom-styles') customStyles = {}; 30 | @Input('disabled') isDisabled = false; 31 | @Input('type') type = 'button'; 32 | @Input('has-color-inherited') 33 | @HostBinding('class.has-color-inherited') hasColorInherited = false; 34 | 35 | @Input() set variant (v: Variants) { 36 | switch(true) { 37 | case v === Variants.BGYELLOW: 38 | this.isBgYellow = true; 39 | break; 40 | case v === Variants.BGGRAY: 41 | this.isBgGray = true; 42 | break; 43 | case v === Variants.ALLTRANSPARENT: 44 | this.isAllTransparent = true; 45 | break; 46 | case v === Variants.PURPLE: 47 | this.isPurple = true; 48 | break; 49 | } 50 | } 51 | 52 | constructor() { } 53 | 54 | ngOnInit(): void { 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/app/answers/answer-questions/answer-questions.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Inject, Input, Output, QueryList, ViewChildren } from '@angular/core'; 2 | import { BASE_BUTTON_VARIANTS, Variants } from 'src/app/shared/base-button/base-button.component'; 3 | import { SectionsState } from '../answers.model'; 4 | import {FormSection} from '../../models/form.section.model'; 5 | 6 | @Component({ 7 | selector: 'app-answer-questions', 8 | templateUrl: './answer-questions.component.html', 9 | styleUrls: ['./answer-questions.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush 11 | }) 12 | export class AnswerQuestionsComponent implements AfterViewInit { 13 | @Input() sections: FormSection[] = []; 14 | @Input() sectionsState: SectionsState; 15 | @Input('scrolled-question-id') scrolledQuestionId: number; 16 | 17 | @Output() showNoteClicked = new EventEmitter(); 18 | 19 | @ViewChildren('question') questionsDivs: QueryList; 20 | 21 | constructor ( 22 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants 23 | ) { } 24 | 25 | ngAfterViewInit () { 26 | if (!this.scrolledQuestionId) { 27 | return; 28 | } 29 | 30 | // This cover the case when from `Notes` tab the user is redirected to a form tab 31 | // which is different than the previous one, shown exactly before the user had visited the `Notes` tab 32 | setTimeout(() => { 33 | let { nativeElement: divToScrollTo } = 34 | this.questionsDivs.find(item => +item.nativeElement.dataset.questionId === this.scrolledQuestionId); 35 | this.scrolledQuestionId = null; 36 | 37 | (divToScrollTo as HTMLDivElement).scrollIntoView(); 38 | divToScrollTo = null; 39 | }); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/app/store/statistics/statistics.actions.ts: -------------------------------------------------------------------------------- 1 | import { LabelValueModel } from '../../models/labelValue.model'; 2 | import { Action } from '@ngrx/store'; 3 | import { actionType } from '../util'; 4 | export class StatisticsActions { 5 | static LOAD = actionType('[Stat] Load'); 6 | static LOADED = actionType('[Stat] Loaded'); 7 | static ERROR = actionType('[Stat] LoadedError'); 8 | } 9 | 10 | export class LoadStatisticAction implements Action { 11 | readonly type = StatisticsActions.LOAD; 12 | 13 | payload: { 14 | key: string, 15 | page: number, 16 | pageSize: number 17 | refresh: boolean 18 | }; 19 | 20 | constructor(key: string, page: number, pageSize: number, refresh = false) { 21 | this.payload = { 22 | key, 23 | page, 24 | pageSize, 25 | refresh 26 | }; 27 | 28 | } 29 | } 30 | export class LoadStatisticsErrorAction implements Action { 31 | readonly type = StatisticsActions.ERROR; 32 | payload: { 33 | key: string 34 | }; 35 | constructor(key: string){ 36 | this.payload = { 37 | key 38 | }; 39 | } 40 | } 41 | export class LoadStatisticsCompleteAction implements Action { 42 | readonly type = StatisticsActions.LOADED; 43 | payload: { 44 | key: string 45 | totalItems: number 46 | totalPages: number 47 | 48 | items: LabelValueModel[] 49 | }; 50 | 51 | constructor(key: string, items: LabelValueModel[], totalPages: number, totalItems: number) { 52 | this.payload = { 53 | key, 54 | items, 55 | totalPages, 56 | totalItems 57 | }; 58 | } 59 | } 60 | export type StatisticsActionTypes = LoadStatisticAction | LoadStatisticsCompleteAction | LoadStatisticsErrorAction; 61 | -------------------------------------------------------------------------------- /src/app/store/observers/observers.state.ts: -------------------------------------------------------------------------------- 1 | import { observersConfig, ObserversStateConfig } from './observers.config'; 2 | 3 | import { keyBy } from 'lodash'; 4 | import { Observer } from '../../models/observer.model'; 5 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 6 | 7 | export class ObserversStateItem { 8 | key: string; 9 | method: string; 10 | 11 | header: string; 12 | subHeader: string; 13 | 14 | page: number; 15 | pageSize: number; 16 | totalPages: number; 17 | totalItems: number; 18 | 19 | loading = false; 20 | error = false; 21 | values = [] as Observer[]; 22 | 23 | constructor(config?: ObserversStateConfig) { 24 | if (config) { 25 | this.key = config.key; 26 | this.method = config.method; 27 | this.header = config.header; 28 | this.subHeader = config.subHeader; 29 | } 30 | } 31 | } 32 | 33 | export class ObserversState { 34 | [key: string]: ObserversStateItem; 35 | } 36 | 37 | export class ObserversCountState { 38 | count: number; 39 | } 40 | export let observersInitialState: ObserversState = keyBy( 41 | observersConfig.map( 42 | (config) => new ObserversStateItem(config) 43 | ), 44 | (value) => value.key 45 | ); 46 | export let observersCountInitialState: ObserversCountState = { count: 0 }; 47 | 48 | export const getObserversState = createFeatureSelector('observers'); 49 | 50 | export const getObserversList = createSelector( 51 | getObserversState, 52 | ((state) => state['observers-list']) 53 | ); 54 | 55 | export const getObserverListValues = createSelector( 56 | getObserversList, 57 | ((state) => state.values) 58 | ); 59 | 60 | export const getSelectedObserver = createSelector( 61 | getObserverListValues, 62 | (state: Observer[], selectedPhoneNumber: string) => state.find(v => v.phone === selectedPhoneNumber) 63 | ); -------------------------------------------------------------------------------- /src/app/answers/notes/notes.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | {{ column.name }} 5 |
6 |
7 | 8 |
9 |
10 |
11 | 12 |
{{ note.tabName }}
13 |
{{ note.questionCode }}
14 |
15 | 16 | 17 | - 18 | 19 |
20 | 21 |
22 | 24 | 25 | 26 |
27 | 28 |
29 | {{ note.text }} 30 |
31 |
32 | 33 | 35 | 36 | 37 |
38 |
39 |
40 |
41 | 42 | 43 |

{{ 'NOTES_EMPTY_ROWS_MESSAGE' | translate }}.

44 |
45 | -------------------------------------------------------------------------------- /src/app/answers/answers/answers.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | $filter-zone-height: 2.8rem; 4 | $label-color: $darker-gray; 5 | 6 | .c-answers__container { 7 | margin-bottom: 2rem; 8 | } 9 | 10 | span.font-weight-bold.h-is-muted { 11 | font-size: 1.1rem; 12 | } 13 | 14 | .c-answers__header-title { 15 | color: $darker-gray; 16 | } 17 | 18 | .c-no-msg { 19 | &__title { 20 | color: $darker-gray; 21 | } 22 | 23 | &__content { 24 | color: $readable-dark-gray; 25 | } 26 | } 27 | 28 | .c-answers__buttons { 29 | .c-answers__button svg:first-child { 30 | margin-right: .5rem; 31 | } 32 | } 33 | 34 | .form-group { 35 | & > .form-control { 36 | border: 1px solid $base-gray; 37 | color: $input-text-color; 38 | padding-top: .8rem; 39 | padding-bottom: .8rem; 40 | font-size: 1.1rem; 41 | height: $filter-zone-height; 42 | 43 | &::placeholder { 44 | color: $input-text-color; 45 | } 46 | } 47 | 48 | & > select.form-control { 49 | min-width: 13rem; 50 | padding: 0.5rem; 51 | } 52 | 53 | & > label { 54 | color: $label-color; 55 | font-size: 1.2rem; 56 | line-height: 30px; 57 | } 58 | } 59 | 60 | .c-answers-checkbox { 61 | width: max-content; 62 | display: flex; 63 | align-items: center; 64 | margin-bottom: 0; 65 | 66 | &__wrapper { 67 | width: max-content; 68 | display: flex; 69 | align-items: center; 70 | transform: translateY(45%); 71 | padding: 0 0.8rem; 72 | font-size: 1.2rem; 73 | 74 | @media all and (max-width: 467px) { 75 | margin-bottom: 2.1rem; 76 | } 77 | 78 | label { 79 | margin-bottom: 0; 80 | cursor: pointer; 81 | color: $darker-gray; 82 | } 83 | 84 | input[type=checkbox] { 85 | display: inline-block !important; 86 | height: 1rem; 87 | width: 1rem; 88 | margin-left: 1rem; 89 | cursor: pointer; 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { AppRoutingModule } from './routing/app.routing.module'; 2 | import { ComponentsModule } from './components/components.module'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { CoreModule } from './core/core.module'; 6 | import { SharedModule } from './shared/shared.module'; 7 | import { AppStoreModule } from './store/store.module'; 8 | import { NgModule } from '@angular/core'; 9 | import { BrowserModule } from '@angular/platform-browser'; 10 | import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; 11 | import { TranslateHttpLoader } from '@ngx-translate/http-loader'; 12 | import { HttpClient } from '@angular/common/http'; 13 | import { ObserversService } from './services/observers.service'; 14 | import { ToastrModule } from 'ngx-toastr'; 15 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 16 | import { NotificationsService } from './services/notifications.service'; 17 | import { AnswersService } from './services/answers.service'; 18 | import { FormsService } from './services/forms.service'; 19 | 20 | export function HttpLoaderFactory(httpClient: HttpClient) { 21 | return new TranslateHttpLoader(httpClient); 22 | } 23 | 24 | @NgModule({ 25 | declarations: [AppComponent], 26 | imports: [ 27 | CoreModule, 28 | BrowserModule, 29 | SharedModule, 30 | AppStoreModule, 31 | AppRoutingModule, 32 | ComponentsModule, 33 | BrowserAnimationsModule, // required animations module 34 | ToastrModule.forRoot(), 35 | TranslateModule.forRoot({ 36 | loader: { 37 | provide: TranslateLoader, 38 | useFactory: HttpLoaderFactory, 39 | deps: [HttpClient], 40 | }, 41 | }), 42 | ], 43 | providers: [ 44 | ObserversService, 45 | NotificationsService, 46 | AnswersService, 47 | FormsService, 48 | ], 49 | 50 | bootstrap: [AppComponent], 51 | }) 52 | export class AppModule {} 53 | -------------------------------------------------------------------------------- /src/app/shared/pagination/pagination.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, HostBinding, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; 2 | import {environment} from '../../../environments/environment'; 3 | 4 | @Component({ 5 | selector: 'app-pagination', 6 | templateUrl: './pagination.component.html', 7 | styleUrls: ['./pagination.component.scss'] 8 | }) 9 | export class PaginationComponent implements OnChanges { 10 | 11 | @Input() 12 | page: number; 13 | 14 | @Input() 15 | pageSize = environment.pageSize; 16 | 17 | @Input() 18 | totalItems: number; 19 | 20 | @Input() 21 | nextEnabled = true; 22 | 23 | @Output() 24 | pageChanged: EventEmitter<{ page: number, pageSize: number }> = new EventEmitter(); 25 | 26 | @HostBinding('class.is-hidden') isHidden = false; 27 | 28 | startingindex: number; 29 | endingIndex: number; 30 | maxPage = 0; 31 | 32 | ngOnChanges(changes: SimpleChanges) { 33 | if (!(changes.page || changes.pageSize || changes.totalItems)) { 34 | return; 35 | } 36 | 37 | this.startingindex = (this.page - 1) * this.pageSize + 1; 38 | this.endingIndex = this.startingindex + this.pageSize - 1; 39 | 40 | if (this.endingIndex > this.totalItems){ 41 | this.endingIndex = this.totalItems; 42 | } 43 | 44 | if ((this.totalItems || this.totalItems === 0) && this.pageSize) { 45 | this.maxPage = Math.ceil(this.totalItems / this.pageSize); 46 | } 47 | 48 | this.isHidden = this.totalItems === 0; 49 | } 50 | 51 | navigateToPage (nextPage) { 52 | this.pageChanged.emit({ 53 | page: nextPage, 54 | pageSize: this.pageSize, 55 | }); 56 | } 57 | 58 | range(page, maxPage) { 59 | const start = Math.max(1, page - 2); 60 | const end = Math.min(page + 2, maxPage); 61 | 62 | const range = []; 63 | for (let i = start; i <= end; i++) { 64 | range.push(i); 65 | } 66 | return range; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/store/answer/answer.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { AnswerThread } from 'src/app/models/answer.thread.model'; 3 | import {CompletedQuestion, CompletedQuestionMap} from 'src/app/models/completed.question.model'; 4 | import { AnswerState } from './answer.reducer'; 5 | import {CompletedAnswerMap} from '../../models/completed.answer.model'; 6 | 7 | export const answer = createFeatureSelector('answer'); 8 | 9 | export const getSelectedAnswers = createSelector( 10 | answer, 11 | (state: AnswerState) => state.selectedAnswer 12 | ) 13 | 14 | export const getSelectedAnswersAsObject = createSelector( 15 | getSelectedAnswers, 16 | (selectedAnswer: CompletedQuestion[]): CompletedQuestionMap => !selectedAnswer 17 | ? undefined 18 | : selectedAnswer.reduce( 19 | (acc, crt) => { 20 | acc[crt.id] = { 21 | ...crt, 22 | answers: crt.answers.reduce( 23 | (_acc, _crt) => (_acc[_crt.optionId] = _crt, _acc), {}), 24 | }; 25 | return acc; 26 | }, 27 | {} 28 | ) 29 | ); 30 | 31 | export const getFilters = createSelector( 32 | answer, 33 | (state: AnswerState) => state.answerFilters, 34 | ); 35 | 36 | export const getAnswerThreads = createSelector( 37 | answer, 38 | (state: AnswerState) => state.threads ? state.threads : [], 39 | ); 40 | 41 | /** IDs uniquely identifying an answer thread. */ 42 | type AnswerThreadIds = { 43 | observerId: number; 44 | pollingStationId: number; 45 | }; 46 | 47 | export const getSpecificThreadByIds = createSelector( 48 | getAnswerThreads, 49 | (threads: AnswerThread[], props: AnswerThreadIds) => 50 | threads.find(thread => (thread.observerId === props.observerId) && 51 | (thread.pollingStationId == props.pollingStationId) 52 | ) 53 | ); 54 | 55 | export const getSelectedAnswersLoadingStatus = createSelector( 56 | answer, 57 | state => state.selectedLoading, 58 | ) 59 | -------------------------------------------------------------------------------- /src/app/store/note/note.effects.ts: -------------------------------------------------------------------------------- 1 | import { distinctUntilChanged, endWith, filter, map, startWith, switchMap, tap, withLatestFrom } from 'rxjs/operators'; 2 | import { Note } from '../../models/note.model'; 3 | import { 4 | LoadNotesAction, 5 | LoadNotesDoneAction, 6 | NoteActionTypes, 7 | setLoadingStatusFromEffects, 8 | } from './note.actions'; 9 | import { Actions, Effect, ofType } from '@ngrx/effects'; 10 | import { ApiService } from '../../core/apiService/api.service'; 11 | import { Injectable } from '@angular/core'; 12 | import { environment } from 'src/environments/environment'; 13 | import { Location } from '@angular/common'; 14 | import { Store } from '@ngrx/store'; 15 | import { note } from './note.selectors'; 16 | import { NoteState } from './note.reducer'; 17 | import { concat } from 'lodash'; 18 | import { of } from 'rxjs'; 19 | 20 | @Injectable() 21 | export class NoteEffects { 22 | private baseUrl: string; 23 | 24 | constructor( 25 | private http: ApiService, 26 | private actions: Actions, 27 | private store: Store, 28 | ) { 29 | this.baseUrl = environment.apiUrl; 30 | } 31 | 32 | @Effect() 33 | notesStream = this.actions.pipe( 34 | ofType(NoteActionTypes.LOAD), 35 | withLatestFrom(this.store.select(note)), 36 | distinctUntilChanged( 37 | ([prevAction]: [LoadNotesAction, NoteState], [crtAction, crtState]: [LoadNotesAction, NoteState]) => 38 | +prevAction.payload.pollingStationId === +crtAction.payload.pollingStationId && !!crtState === true 39 | ), 40 | map(([action]) => action), 41 | switchMap((a: LoadNotesAction) => { 42 | const notesUrl: string = Location.joinWithSlash( 43 | this.baseUrl, 44 | '/api/v2/note' 45 | ); 46 | 47 | return this.http.get(notesUrl, { body: a.payload }).pipe( 48 | map((notes) => new LoadNotesDoneAction(notes as any)), 49 | startWith(setLoadingStatusFromEffects({ isLoading: true, })), 50 | endWith(setLoadingStatusFromEffects({ isLoading: false, })) 51 | ); 52 | }), 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monitorizare-vot", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "angular-cli": {}, 6 | "scripts": { 7 | "start": "ng serve", 8 | "start-qa": "ng serve --configuration=qa", 9 | "start-prod": "ng serve --configuration=production", 10 | "start-local": "ng serve --configuration=local", 11 | "build": "ng build", 12 | "build-prod": "ng build --configuration=production", 13 | "build-qa": "ng build --configuration=qa", 14 | "build-local": "ng build --configuration=local", 15 | "lint": "tslint \"src/**/*.ts\"" 16 | }, 17 | "private": true, 18 | "dependencies": { 19 | "@angular/animations": "^9.1.12", 20 | "@angular/cdk": "^9.1.12", 21 | "@angular/common": "^9.1.12", 22 | "@angular/compiler": "^9.1.12", 23 | "@angular/core": "^9.1.12", 24 | "@angular/forms": "^9.1.12", 25 | "@angular/localize": "^9.1.12", 26 | "@angular/platform-browser": "^9.1.12", 27 | "@angular/platform-browser-dynamic": "^9.1.12", 28 | "@angular/platform-server": "^9.1.12", 29 | "@angular/router": "^9.1.12", 30 | "@auth0/angular-jwt": "^4.2.0", 31 | "@ng-bootstrap/ng-bootstrap": "^7.0.0", 32 | "@ngrx/effects": "^9.1.2", 33 | "@ngrx/store": "^9.1.2", 34 | "@ngrx/store-devtools": "^9.1.2", 35 | "@ngx-translate/core": "^13.0.0", 36 | "@ngx-translate/http-loader": "^6.0.0", 37 | "bootstrap": "^4.5.2", 38 | "file-saver": "^2.0.2", 39 | "font-awesome": "^4.7.0", 40 | "lodash": "^4.17.19", 41 | "ng-multiselect-dropdown": "^0.2.6", 42 | "ngx-toastr": "^12.1.0", 43 | "rxjs": "~6.6.0", 44 | "tslib": "^2.0.0", 45 | "zone.js": "~0.10.2" 46 | }, 47 | "devDependencies": { 48 | "@angular-devkit/build-angular": "~0.901.8", 49 | "@angular/cli": "^9.1.12", 50 | "@angular/compiler-cli": "^9.1.12", 51 | "@types/file-saver": "^2.0.1", 52 | "@types/lodash": "^4.14.136", 53 | "@types/node": "^12.11.1", 54 | "codelyzer": "^6.0.1", 55 | "ts-node": "~8.3.0", 56 | "tslint": "~6.1.0", 57 | "typescript": ">=3.0.0 <3.9.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/components/notifications/notification-history/notification-history.component.html: -------------------------------------------------------------------------------- 1 |
2 | 11 |
{{'NOTIFICATION_HISTORY' | translate}}
12 | 13 | 20 | 21 | 22 | 23 | {{ 'UNSPECIFIED' | translate }} 24 | 25 | 26 | {{ row[prop] | date: 'shortTime' }} 27 | 28 | 29 | {{ row[prop].length }} {{'OBSERVERS' | translate}} 30 | 31 | 32 | {{row[prop]}} 33 | 34 | 35 | 36 | 37 | 45 | 46 | 47 |
48 | -------------------------------------------------------------------------------- /src/app/components/notifications/notification-history/notification-history.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit} from '@angular/core'; 2 | import {Store} from '@ngrx/store'; 3 | import {AppState} from '../../../store/store.module'; 4 | import {NotificationsActions} from '../../../store/notifications/notifications.actions'; 5 | import {NotificationsState, selectNotifications} from '../../../store/notifications/notifications.state'; 6 | import {Observable} from 'rxjs'; 7 | import {TranslateService} from '@ngx-translate/core'; 8 | import {TableColumn, TableColumnTranslated} from '../../../table/table.model'; 9 | import {environment} from '../../../../environments/environment'; 10 | 11 | const DEFAULT_PAGE_SIZE = environment.pageSize; 12 | const TABLE_COLUMNS: TableColumn[] = [ 13 | { name: 'SENT_BY', propertyName: 'senderAccount'}, 14 | { name: 'NGO', propertyName: 'senderNgoName'}, 15 | { name: 'TITLE', propertyName: 'title'}, 16 | { name: 'MESSAGE', propertyName: 'body'}, 17 | { name: 'SENT_TO', propertyName: 'sentObserverIds'}, 18 | { name: 'SENT_AT', propertyName: 'insertedAt'} 19 | ]; 20 | 21 | @Component({ 22 | selector: 'app-notification-history', 23 | templateUrl: './notification-history.component.html', 24 | styleUrls: ['./notification-history.component.scss'], 25 | }) 26 | export class NotificationHistoryComponent implements OnInit { 27 | tableColumns: TableColumnTranslated[] = []; 28 | notificationState$: Observable; 29 | 30 | constructor( 31 | private store: Store, 32 | private translateService: TranslateService 33 | ) {} 34 | 35 | ngOnInit() { 36 | this.loadNotifications(1); 37 | this.notificationState$ = this.store.select(selectNotifications); 38 | this.tableColumns = TABLE_COLUMNS.map( 39 | ({name, ...rest}) => ({ ...rest, name: this.translateService.get(name)})); 40 | } 41 | 42 | private loadNotifications(page: number) { 43 | this.store.dispatch(NotificationsActions.load({page, pageSize: DEFAULT_PAGE_SIZE})) 44 | } 45 | 46 | pageChanged(e) { 47 | this.loadNotifications(e.page); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-card/observer-card.component.html: -------------------------------------------------------------------------------- 1 |
9 |
10 | 11 |
12 | 13 |
14 |
15 | {{ observer.name }} 16 |
17 | 25 | 29 | 30 | {{ observer.phone }} 31 |
32 | 57 |
58 | -------------------------------------------------------------------------------- /src/app/core/token/token.service.ts: -------------------------------------------------------------------------------- 1 | import {Observable, Observer} from 'rxjs'; 2 | import {Injectable} from '@angular/core'; 3 | import { JwtHelperService } from '@auth0/angular-jwt'; 4 | import { User } from '../../models/user.model'; 5 | 6 | @Injectable() 7 | export class TokenService { 8 | jwtHelper = new JwtHelperService(); 9 | 10 | public tokenKey = 'token-id'; 11 | private _token: string = undefined; 12 | private _user: User; 13 | 14 | private _isRefreshing: boolean; 15 | 16 | tokenStream: Observable; 17 | observers: Observer[] = []; 18 | 19 | 20 | public get isRefreshing() { 21 | return this._isRefreshing; 22 | } 23 | 24 | constructor() { 25 | this._token = localStorage.getItem(this.tokenKey); 26 | this._user = this.getUserFromToken(this._token); 27 | this.setTokenStream(); 28 | } 29 | 30 | setTokenStream() { 31 | this.tokenStream = Observable.create((obs: Observer) => { 32 | obs.next(this._token); 33 | this.observers.push(obs); 34 | return () => { 35 | this.observers = this.observers.filter(stateObserver => stateObserver === obs); 36 | }; 37 | }); 38 | } 39 | 40 | public get userName() { 41 | return this._user.sub; 42 | } 43 | public get user() { 44 | return this._user; 45 | } 46 | public get token() { 47 | return this._token; 48 | } 49 | public set token(value) { 50 | this._token = value; 51 | if (value !== undefined){ 52 | localStorage.setItem(this.tokenKey, value); 53 | this._user = this.getUserFromToken(value); 54 | } else { 55 | localStorage.removeItem(this.tokenKey); 56 | } 57 | this.observers.forEach(obs => obs.next(value)); 58 | } 59 | private getUserFromToken(token: string): User { 60 | const decodedToken = this.jwtHelper.decodeToken(token); 61 | if (!decodedToken) return null; 62 | return { 63 | sub: decodedToken.sub, 64 | idNgo: decodedToken.IdNgo, 65 | userType: decodedToken.UserType, 66 | organizer: decodedToken.Organizer 67 | }; 68 | } 69 | public isloggedIn() { 70 | return this.token ? true : false; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/app/components/forms/predefined-options-modal/predefined-options-modal.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit} from '@angular/core'; 2 | import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; 3 | import predefinedOptions from '../../../../assets/configs/predefined-options.json'; 4 | 5 | interface Category { 6 | label: string; 7 | options: Option[]; 8 | } 9 | 10 | interface Option { 11 | label: string; 12 | check?: boolean; 13 | } 14 | 15 | @Component({ 16 | selector: 'app-predefined-options-modal', 17 | templateUrl: './predefined-options-modal.component.html', 18 | styleUrls: ['./predefined-options-modal.component.scss'] 19 | }) 20 | export class PredefinedOptionsModalComponent implements OnInit { 21 | data: Category[]; 22 | private readonly checkboxPersistentData: any; 23 | 24 | constructor(public modal: NgbActiveModal) { 25 | if (!localStorage.getItem('selectedPredefinedOptions')) { 26 | localStorage.setItem('selectedPredefinedOptions', '{}'); 27 | } 28 | try { 29 | this.checkboxPersistentData = JSON.parse(localStorage.getItem('selectedPredefinedOptions')); 30 | } catch { 31 | localStorage.setItem('selectedPredefinedOptions', '{}'); 32 | this.checkboxPersistentData = {}; 33 | } 34 | 35 | this.data = predefinedOptions; 36 | 37 | this.data.forEach(category => { 38 | category.options.forEach(option => { 39 | if (option.label in this.checkboxPersistentData) 40 | this.check(option, this.checkboxPersistentData[option.label] === 'true') 41 | }) 42 | }) 43 | } 44 | 45 | check(option: Option, value: boolean) { 46 | option.check = value; 47 | this.checkboxPersistentData[option.label] = value ? 'true' : 'false'; 48 | localStorage.setItem('selectedPredefinedOptions', JSON.stringify(this.checkboxPersistentData)); 49 | } 50 | 51 | getChecked() { 52 | const result = [] 53 | this.data.forEach(category => { 54 | category.options.forEach(option => { 55 | if (option.check) 56 | result.push(option.label) 57 | }) 58 | }) 59 | return result; 60 | } 61 | 62 | ngOnInit() { 63 | } 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/app/store/statistics/statistics.reducer.ts: -------------------------------------------------------------------------------- 1 | import { shouldLoadPage } from '../../shared/pagination.service'; 2 | import { StatisticsActions, StatisticsActionTypes } from './statistics.actions'; 3 | import { statisticsInitialState, StatisticsStateItem } from './statistics.state'; 4 | export function statisticsReducer(state = statisticsInitialState, action: StatisticsActionTypes) { 5 | 6 | switch (action.type) { 7 | case StatisticsActions.LOAD: 8 | case StatisticsActions.LOADED: 9 | const reducedItem = statisticsItemReducer(state[action.payload.key], action); 10 | if (reducedItem === state[action.payload.key]) { 11 | return state; 12 | } 13 | return Object.assign({}, state, { 14 | [action.payload.key]: reducedItem 15 | }); 16 | 17 | default: 18 | return state; 19 | } 20 | 21 | 22 | } 23 | 24 | export function statisticsItemReducer(state: StatisticsStateItem, action: any) { 25 | switch (action.type) { 26 | case StatisticsActions.LOAD: 27 | const newList = action.payload.refresh, 28 | shouldLoadList = shouldLoadPage(action.payload.page, action.payload.pageSize, state.values.length); 29 | return Object.assign({}, state, { 30 | loading: shouldLoadList, 31 | error: false, 32 | page: action.payload.page, 33 | pageSize: action.payload.pageSize, 34 | values: newList ? [] : state.values 35 | }); 36 | case StatisticsActions.LOADED: 37 | return Object.assign({}, state, { 38 | loading: false, 39 | error: false, 40 | values: state.values.concat(action.payload.items), 41 | totalPages: action.payload.totalPages, 42 | totalItems: action.payload.totalItems 43 | }); 44 | case StatisticsActions.ERROR: 45 | return Object.assign({}, state, { 46 | error: true, 47 | loading: false 48 | }); 49 | default: 50 | return state; 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/store/county/county.effects.ts: -------------------------------------------------------------------------------- 1 | import { CountyActionTypes, CountyAnswersErrorAction, CountyAnswersSuccessAction, CountyPollingStationErrorAction, CountyPollingStationSuccessAction } from './county.actions'; 2 | import { Injectable } from '@angular/core'; 3 | import {Location} from '@angular/common'; 4 | import { Actions, createEffect, Effect, ofType } from '@ngrx/effects'; 5 | import { ApiService } from '../../core/apiService/api.service'; 6 | import { environment } from '../../../environments/environment'; 7 | import { catchError, filter, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; 8 | import { of } from 'rxjs'; 9 | import { County } from './county.state'; 10 | import { Store } from '@ngrx/store'; 11 | import { AppState } from '../store.module'; 12 | import { getCounties } from './county.selectors'; 13 | 14 | @Injectable() 15 | export class CountyEffects { 16 | private baseURL: string = environment.apiUrl; 17 | private fetchCountiesURL = Location.joinWithSlash(this.baseURL, '/api/v1/county'); 18 | 19 | constructor( 20 | private actions$: Actions, 21 | private apiService: ApiService, 22 | private store: Store 23 | ) { } 24 | 25 | fetchCounties$ = createEffect( 26 | () => this.actions$.pipe( 27 | ofType(CountyActionTypes.FETCH_COUNTIES_FROM_ANSWERS), 28 | withLatestFrom(this.store.select(getCounties)), 29 | filter(([, currentCounties]) => !!currentCounties === false), 30 | switchMap( 31 | () => this.apiService.get(this.fetchCountiesURL).pipe( 32 | map((counties: County[]) => new CountyAnswersSuccessAction(counties)), 33 | catchError((err) => of(new CountyAnswersErrorAction(err.message)) 34 | ) 35 | ) 36 | ), 37 | ) 38 | ); 39 | 40 | @Effect() getPollingStations$ = this.actions$.pipe( 41 | ofType(CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS), 42 | switchMap( 43 | () => this.apiService.get(this.fetchCountiesURL).pipe( 44 | map((counties: County[]) => new CountyPollingStationSuccessAction(counties)), 45 | catchError((err) => of(new CountyPollingStationErrorAction(err.message)) 46 | ) 47 | ) 48 | ), 49 | ) 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-import/observer-import.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 9 |
10 | 12 | 13 | 14 | 15 |   16 | {{ 'BACK' | translate }} 17 |
18 |
19 |
20 |
21 | 22 |
23 |

{{ 'OBSERVERS_IMPORT' | translate }}

24 |
25 | 26 |
27 |
32 |
33 | 34 | 40 |
41 | 42 |
43 | 44 | 50 |
51 | 52 | 59 | {{ 'IMPORT' | translate }} 60 | 61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /src/app/answers/answer-questions/answer-questions.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | $question-title-background: #FAFAFA; 4 | $flagged-question-title-background: #FFF5F5; 5 | $question-text-color: #474747; 6 | 7 | .c-answer-section { 8 | &:not(:first-child) { 9 | margin-top: 1rem; 10 | } 11 | 12 | &__desc { 13 | color: $light-text-gray; 14 | width: 50%; 15 | 16 | @media all and (max-width: 980px) { 17 | & { 18 | width: 70%; 19 | } 20 | } 21 | 22 | @media all and (max-width: 650px) { 23 | width: 100%; 24 | } 25 | } 26 | 27 | &__questions { 28 | display: grid; 29 | grid-template-columns: 1fr 1fr; 30 | gap: 1rem; 31 | margin-top: 1rem; 32 | 33 | @media all and (max-width: 600px) { 34 | grid-template-columns: 1fr; 35 | } 36 | } 37 | } 38 | 39 | .c-answer-question { 40 | border: 1px solid $border-color-gray; 41 | border-radius: 4px; 42 | color: $question-text-color; 43 | height: min-content; 44 | 45 | &.is-flagged { 46 | border-color: $delete-color; 47 | } 48 | 49 | &__title { 50 | padding: 1rem; 51 | background-color: $question-title-background; 52 | 53 | .c-answer-question.is-flagged & { 54 | background-color: $flagged-question-title-background; 55 | } 56 | } 57 | 58 | &__options { 59 | display: flex; 60 | flex-direction: column; 61 | row-gap: .8rem; 62 | padding: 1rem; 63 | } 64 | 65 | &__option { 66 | display: flex; 67 | column-gap: 2rem; 68 | 69 | &.is-option-text { 70 | display: flex; 71 | } 72 | } 73 | 74 | .c-answer-checkbox { 75 | line-height: .5rem; 76 | } 77 | } 78 | 79 | .c-answer-note { 80 | &__button { 81 | width: max-content; 82 | display: block; 83 | margin-left: auto; 84 | margin-right: 1rem; 85 | margin-bottom: .5rem; 86 | 87 | &.has-question-flagged { 88 | color: $delete-color; 89 | } 90 | } 91 | } 92 | 93 | .checkboxOption { 94 | display: inline-flex; 95 | gap: 30px; 96 | } 97 | 98 | .textOption { 99 | display: inline-flex; 100 | gap: 30px; 101 | } 102 | -------------------------------------------------------------------------------- /src/app/store/note/note.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { Note, NoteMap } from '../../models/note.model'; 3 | import { getSelectedAnswersAsObject } from '../answer/answer.selectors'; 4 | import { getAllQuestionsGroupedByTabId, getFormItems, getFormItemsById, getFullyLoadedForms } from '../form/form.selectors'; 5 | 6 | import { NoteState } from './note.reducer'; 7 | 8 | const emptyResult = []; 9 | const displayedTextLenLimit = 185; 10 | 11 | export const note = createFeatureSelector('note'); 12 | 13 | export const getNotes = createSelector( 14 | note, 15 | (state: NoteState) => state.notes || [], 16 | ); 17 | 18 | export const getNotesAsObject = createSelector( 19 | getNotes, 20 | (notes: Note[]): NoteMap => !notes 21 | ? {} 22 | : notes.reduce((acc, crt) => (acc[crt.questionId] = crt, acc), {}) 23 | ); 24 | 25 | export const getNotesMergedWithQuestions = createSelector( 26 | getNotes, 27 | getFormItemsById, 28 | getAllQuestionsGroupedByTabId, 29 | getSelectedAnswersAsObject, 30 | (notes, tabs, questionsByTabId, selectedAnswers) => { 31 | const tabsLen = Object.keys(tabs).length; 32 | if (tabsLen !== Object.keys(questionsByTabId).length || tabsLen === 0) { 33 | return emptyResult; 34 | } 35 | 36 | return notes.map(note => { 37 | const correspondingQuestion = questionsByTabId[note.formId]?.[note.questionId]; 38 | 39 | return { 40 | ...note, 41 | displayedText: note.text.length > displayedTextLenLimit ? note.text.slice(0, displayedTextLenLimit + 1) + '...' : note.text, 42 | ...correspondingQuestion 43 | ? { 44 | tabName: tabs[note.formId].description, 45 | questionCode: correspondingQuestion.code, 46 | isQuestionFlagged: correspondingQuestion.optionsToQuestions.some(o => o.flagged && selectedAnswers[correspondingQuestion.id]?.answers[o.optionId]), 47 | hasCorrespondingQuestion: true, 48 | } 49 | : { 50 | hasCorrespondingQuestion: false, 51 | } 52 | } 53 | }); 54 | } 55 | ); 56 | 57 | export const getNotesLoadingStatus = createSelector( 58 | note, 59 | state => state.loading, 60 | ) 61 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { ErrorIndicatorComponent } from './error-indicator/error-indicator.component'; 2 | import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'; 3 | import { PaginationComponent } from './pagination/pagination.component'; 4 | import { CommonModule } from '@angular/common'; 5 | import { NgModule } from '@angular/core'; 6 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 7 | import { RouterModule } from '@angular/router'; 8 | import { 9 | NgbCollapseModule, 10 | NgbNavModule, 11 | NgbDropdownModule, 12 | NgbTooltipModule 13 | } from '@ng-bootstrap/ng-bootstrap'; 14 | import { TranslateModule } from '@ngx-translate/core'; 15 | import { IconToggleInputComponent } from './icon-toggle-input/icon-toggle-input.component'; 16 | import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component'; 17 | import { BaseButtonComponent } from './base-button/base-button.component'; 18 | import { BaseCheckboxComponent } from './base-checkbox/base-checkbox.component'; 19 | import { BaseDropdownComponent } from './base-dropdown/base-dropdown.component'; 20 | 21 | @NgModule({ 22 | imports: [ 23 | FormsModule, 24 | ReactiveFormsModule, 25 | CommonModule, 26 | NgbCollapseModule, 27 | NgbDropdownModule, 28 | NgbNavModule, 29 | RouterModule, 30 | TranslateModule, 31 | NgbTooltipModule 32 | ], 33 | exports: [ 34 | FormsModule, 35 | ReactiveFormsModule, 36 | CommonModule, 37 | NgbCollapseModule, 38 | NgbNavModule, 39 | NgbDropdownModule, 40 | RouterModule, 41 | PaginationComponent, 42 | LoadingIndicatorComponent, 43 | ErrorIndicatorComponent, 44 | TranslateModule, 45 | IconToggleInputComponent, 46 | NgbTooltipModule, 47 | BaseButtonComponent, 48 | BaseCheckboxComponent, 49 | BaseDropdownComponent, 50 | ], 51 | declarations: [ 52 | PaginationComponent, 53 | LoadingIndicatorComponent, 54 | ErrorIndicatorComponent, 55 | IconToggleInputComponent, 56 | ConfirmationModalComponent, 57 | BaseButtonComponent, 58 | BaseCheckboxComponent, 59 | BaseDropdownComponent, 60 | ], 61 | providers: [], 62 | }) 63 | export class SharedModule {} 64 | -------------------------------------------------------------------------------- /src/app/models/answer.extra.model.ts: -------------------------------------------------------------------------------- 1 | import { isString, isDate } from 'lodash'; 2 | 3 | export interface AnswerExtraConstructorData { 4 | lastModified: string; 5 | observerArrivalTime: string; 6 | observerLeaveTime: string; 7 | numberOfVotersOnTheList: number; 8 | numberOfCommissionMembers: number; 9 | numberOfFemaleMembers: number; 10 | minPresentMembers: number; 11 | chairmanPresence: boolean; 12 | singlePollingStationOrCommission: boolean; 13 | adequatePollingStationSize: boolean; 14 | } 15 | export class AnswerExtra { 16 | lastModified: Date; 17 | observerArrivalTime: Date; 18 | observerLeaveTime: Date; 19 | 20 | numberOfVotersOnTheList: number; 21 | numberOfCommissionMembers: number; 22 | numberOfFemaleMembers: number; 23 | minPresentMembers: number; 24 | chairmanPresence: boolean; 25 | singlePollingStationOrCommission: boolean; 26 | adequatePollingStationSize: boolean; 27 | 28 | constructor(formInfo?: AnswerExtraConstructorData) { 29 | if (!formInfo) { 30 | return; 31 | } 32 | 33 | checkForPropValue( 34 | formInfo.lastModified, 35 | (val) => (this.lastModified = val) 36 | ); 37 | checkForPropValue( 38 | formInfo.observerArrivalTime, 39 | (val) => (this.observerArrivalTime = val) 40 | ); 41 | checkForPropValue( 42 | formInfo.observerLeaveTime, 43 | (val) => (this.observerLeaveTime = val) 44 | ); 45 | this.numberOfVotersOnTheList = formInfo.numberOfVotersOnTheList; 46 | this.numberOfCommissionMembers = formInfo.numberOfCommissionMembers; 47 | this.numberOfFemaleMembers = formInfo.numberOfFemaleMembers; 48 | this.minPresentMembers = formInfo.minPresentMembers; 49 | this.chairmanPresence = formInfo.chairmanPresence; 50 | this.singlePollingStationOrCommission = 51 | formInfo.singlePollingStationOrCommission; 52 | this.adequatePollingStationSize = formInfo.adequatePollingStationSize; 53 | 54 | function checkForPropValue(value, setPropertyFn: (val: Date) => void) { 55 | if (!value) { 56 | return; 57 | } 58 | if (isString(value)) { 59 | value = new Date(value); 60 | } 61 | 62 | if (isDate(value)) { 63 | setPropertyFn(value); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/store/observers/observers.reducer.ts: -------------------------------------------------------------------------------- 1 | import {shouldLoadPage} from '../../shared/pagination.service'; 2 | import {ObserversActions, ObserversActionTypes} from './observers.actions'; 3 | import { observersInitialState, ObserversStateItem, observersCountInitialState } from './observers.state'; 4 | 5 | export function observersReducer(state = observersInitialState, action: ObserversActionTypes) { 6 | 7 | switch (action.type) { 8 | case ObserversActions.LOAD: 9 | case ObserversActions.LOADED: 10 | const reducedItem = observersItemReducer(state[action.payload.key], action); 11 | if (reducedItem === state[action.payload.key]) { 12 | return state; 13 | } 14 | return Object.assign({}, state, { 15 | [action.payload.key]: reducedItem 16 | }); 17 | 18 | default: 19 | return state; 20 | } 21 | 22 | 23 | } 24 | export function observersCountReducer(state = observersCountInitialState, action: ObserversActionTypes) { 25 | switch (action.type) { 26 | 27 | case ObserversActions.LOADEDOBSERVERSTOTALCOUNT: 28 | return Object.assign({}, state, action.payload); 29 | 30 | default: 31 | return state; 32 | } 33 | 34 | 35 | } 36 | 37 | export function observersItemReducer(state: ObserversStateItem, action: any) { 38 | switch (action.type) { 39 | case ObserversActions.LOAD: 40 | const newList = action.payload.refresh, 41 | shouldLoadList = shouldLoadPage(action.payload.page, action.payload.pageSize, state.values.length); 42 | return Object.assign({}, state, { 43 | loading: shouldLoadList, 44 | error: false, 45 | page: action.payload.page, 46 | pageSize: action.payload.pageSize, 47 | values: newList ? [] : state.values 48 | }); 49 | case ObserversActions.LOADED: 50 | return Object.assign({}, state, { 51 | loading: false, 52 | error: false, 53 | values: state.values.concat(action.payload.items), 54 | totalPages: action.payload.totalPages, 55 | totalItems: action.payload.totalItems 56 | }); 57 | case ObserversActions.ERROR: 58 | return Object.assign({}, state, { 59 | error: true, 60 | loading: false 61 | }); 62 | default: 63 | return state; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/components/observers/observers.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | $notif-color: $primary-gray; 4 | $label-color: $darker-gray; 5 | $filter-zone-height: 2.8rem; 6 | 7 | .c-observers__container { 8 | margin-bottom: 2rem; 9 | } 10 | 11 | span.font-weight-bold.h-is-muted { 12 | font-size: 1.1rem; 13 | } 14 | 15 | .c-observers__header-title { 16 | color: $darker-gray; 17 | } 18 | 19 | .c-no-msg { 20 | &__title { 21 | color: $darker-gray; 22 | } 23 | 24 | &__content { 25 | color: $readable-dark-gray; 26 | } 27 | } 28 | 29 | .c-observers__buttons { 30 | app-base-button:not(:last-child) { 31 | margin-right: 1rem; 32 | } 33 | 34 | app-base-button svg:first-child { 35 | margin-right: .5rem; 36 | } 37 | 38 | app-base-button .content ~ svg { 39 | margin-left: .5rem; 40 | } 41 | 42 | @media all and (max-width: 760px) { 43 | & { 44 | margin-top: 1.5rem; 45 | } 46 | 47 | app-base-button { 48 | margin-top: .5rem; 49 | } 50 | } 51 | } 52 | 53 | .c-observers__header { 54 | @media all and (max-width: 780px) { 55 | & { 56 | flex-wrap: wrap; 57 | } 58 | } 59 | } 60 | 61 | .c-observers__dropdown { 62 | @media all and (max-width: 860px) { 63 | & { 64 | margin-right: .5rem; 65 | } 66 | } 67 | } 68 | 69 | .c-dropdown-button { 70 | &--delete { 71 | color: $delete-color; 72 | } 73 | 74 | &--notification { 75 | color: $notif-color; 76 | } 77 | } 78 | 79 | .form-group { 80 | & > input.form-control { 81 | border: 1px solid $base-gray; 82 | color: $input-text-color; 83 | padding-top: .8rem; 84 | padding-bottom: .8rem; 85 | font-size: 1.1rem; 86 | height: $filter-zone-height; 87 | 88 | &::placeholder { 89 | color: $input-text-color; 90 | } 91 | } 92 | 93 | & > label { 94 | color: $label-color; 95 | font-size: 1.2rem; 96 | line-height: 30px; 97 | } 98 | } 99 | 100 | .c-reset-pwd { 101 | &__title { 102 | padding: 1rem; 103 | color: $darker-gray; 104 | } 105 | 106 | &__buttons { 107 | padding: 1rem; 108 | display: flex; 109 | } 110 | 111 | &__button:not(:first-child) { 112 | margin-left: 1.5rem; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/app/services/observers.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {ApiService} from '../core/apiService/api.service'; 3 | import {Observer} from '../models/observer.model'; 4 | import {environment} from 'src/environments/environment'; 5 | import {Location} from '@angular/common'; 6 | import {Observable} from 'rxjs'; 7 | import {first} from 'rxjs/operators'; 8 | 9 | @Injectable() 10 | export class ObserversService { 11 | private baseUrl: string; 12 | 13 | constructor(private http: ApiService) { 14 | this.baseUrl = environment.apiUrl; 15 | } 16 | 17 | addNewObserver(observer: Observer) { 18 | const url: string = Location.joinWithSlash( 19 | this.baseUrl, 20 | `/api/v1/observer?Phone=${observer.phone}&Pin=${observer.pin}&Name=${observer.name}&SendSMS=${observer.sendSMS}` 21 | ); 22 | return this.http.post(url, observer); 23 | } 24 | 25 | saveChanges(observer: { [k: string]: any }, info: Observer) { 26 | const url: string = Location.joinWithSlash( 27 | this.baseUrl, 28 | '/api/v1/observer' 29 | ); 30 | return this.http.put(url, { ...observer, observerId: info.id }); 31 | } 32 | 33 | deleteObserver(id: string) { 34 | const url: string = Location.joinWithSlash( 35 | this.baseUrl, 36 | `/api/v1/observer?id=${id}` 37 | ); 38 | return this.http.delete(url); 39 | } 40 | 41 | resetPasswordObserver(phone: string, pin: string) { 42 | const url: string = Location.joinWithSlash( 43 | this.baseUrl, 44 | '/api/v1/observer/reset' 45 | ); 46 | return this.http.post(url, { 47 | action: 'reset-password', 48 | phoneNumber: phone, 49 | pin, 50 | }); 51 | } 52 | 53 | getObserver(id: string) { 54 | const url: string = Location.joinWithSlash( 55 | this.baseUrl, 56 | `/api/v1/observer?Number=${id}` 57 | ); 58 | return this.http.get(url); 59 | } 60 | 61 | countObservers(): Observable { 62 | const url = Location.joinWithSlash(this.baseUrl, '/api/v1/observer/count'); 63 | return this.http.get(url).pipe(first()); 64 | } 65 | 66 | uploadCsv(formData: any) { 67 | const url: string = Location.joinWithSlash( 68 | this.baseUrl, 69 | `/api/v1/observer/import` 70 | ); 71 | return this.http.post(url, formData); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/answers/answer-notification/answer-notification.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | import { Store } from '@ngrx/store'; 4 | import { concat, Observable, of, timer } from 'rxjs'; 5 | import { catchError, map, mapTo, pluck, tap } from 'rxjs/operators'; 6 | import { NotificationsService } from 'src/app/services/notifications.service'; 7 | import { BASE_BUTTON_VARIANTS, Variants } from 'src/app/shared/base-button/base-button.component'; 8 | import { getSpecificThreadByIds } from 'src/app/store/answer/answer.selectors'; 9 | import { AppState } from 'src/app/store/store.module'; 10 | 11 | @Component({ 12 | selector: 'answer-notification', 13 | templateUrl: './answer-notification.component.html', 14 | styleUrls: ['./answer-notification.component.scss'] 15 | }) 16 | export class AnswerNotificationComponent { 17 | observerName$ = this.store.select(getSpecificThreadByIds, { 18 | observerId: +this.route.snapshot.params.observerId, 19 | pollingStationId: +this.route.snapshot.params.pollingStationId, 20 | }) 21 | .pipe( 22 | tap(v => v === void 0 && this.router.navigate(['/answers'], { relativeTo: this.route })), 23 | pluck('observerName'), 24 | ); 25 | actionResultMessage$: Observable = of(''); 26 | 27 | buttonStyles = { 28 | fontWeight: 'normal', 29 | padding: '0.6rem 1rem', 30 | height: 'auto', 31 | borderRadius: '4px', 32 | } 33 | 34 | constructor ( 35 | private router: Router, 36 | private route: ActivatedRoute, 37 | private store: Store, 38 | private notificationService: NotificationsService, 39 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants 40 | ) { } 41 | 42 | onSubmit (values: { title: string, message: string }) { 43 | this.actionResultMessage$ = this.notificationService.pushNotification({ 44 | ...values, 45 | channel: 'Firebase', 46 | from: 'Monitorizare Vot', 47 | recipients: [this.route.snapshot.params.observerId] 48 | }).pipe( 49 | mapTo('NOTES_SENT_SUCCESS'), 50 | catchError(err => of('NOTES_SENT_FAILURE')), 51 | ); 52 | 53 | this.actionResultMessage$ = concat( 54 | this.actionResultMessage$, 55 | timer(5000).pipe(mapTo('')), 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warning" 8 | }, 9 | "component-class-suffix": true, 10 | "contextual-lifecycle": true, 11 | "directive-class-suffix": true, 12 | "directive-selector": [ 13 | true, 14 | "attribute", 15 | "app", 16 | "camelCase" 17 | ], 18 | "component-selector": [ 19 | true, 20 | "element", 21 | "app", 22 | "kebab-case" 23 | ], 24 | "import-blacklist": [ 25 | true, 26 | "rxjs/Rx" 27 | ], 28 | "interface-name": false, 29 | "max-classes-per-file": false, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-consecutive-blank-lines": false, 47 | "no-console": [ 48 | true, 49 | "debug", 50 | "info", 51 | "time", 52 | "timeEnd", 53 | "trace" 54 | ], 55 | "no-empty": false, 56 | "no-inferrable-types": [ 57 | true, 58 | "ignore-params" 59 | ], 60 | "no-non-null-assertion": true, 61 | "no-redundant-jsdoc": true, 62 | "no-switch-case-fall-through": true, 63 | "no-use-before-declare": true, 64 | "no-var-requires": false, 65 | "object-literal-key-quotes": [ 66 | true, 67 | "as-needed" 68 | ], 69 | "object-literal-sort-keys": false, 70 | "ordered-imports": false, 71 | "quotemark": [ 72 | true, 73 | "single" 74 | ], 75 | "trailing-comma": false, 76 | "no-conflicting-lifecycle": true, 77 | "no-host-metadata-property": true, 78 | "no-input-rename": true, 79 | "no-inputs-metadata-property": true, 80 | "no-output-native": true, 81 | "no-output-on-prefix": true, 82 | "no-output-rename": true, 83 | "no-outputs-metadata-property": true, 84 | "template-banana-in-box": true, 85 | "template-no-negated-async": true, 86 | "use-lifecycle-interface": true, 87 | "use-pipe-transform-interface": true 88 | }, 89 | "rulesDirectory": [ 90 | "node_modules/codelyzer" 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /src/app/components/observers/observer-import/observer-import.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { ToastrService } from 'ngx-toastr'; 4 | import { TokenService } from 'src/app/core/token/token.service'; 5 | import { ObserversService } from 'src/app/services/observers.service'; 6 | import { BASE_BUTTON_VARIANTS, Variants } from 'src/app/shared/base-button/base-button.component'; 7 | 8 | @Component({ 9 | selector: 'observer-import', 10 | templateUrl: './observer-import.component.html', 11 | styleUrls: ['./observer-import.component.scss'] 12 | }) 13 | export class ObserverImportComponent implements OnInit { 14 | 15 | formGroup: FormGroup 16 | 17 | constructor ( 18 | private fb: FormBuilder, 19 | private tokenService: TokenService, 20 | private toastr: ToastrService, 21 | private observerService: ObserversService, 22 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants 23 | ) { } 24 | 25 | ngOnInit(): void { 26 | this.formGroup = this.buildForm(); 27 | } 28 | 29 | buildForm(): FormGroup { 30 | const user = this.tokenService.user; 31 | const isAdmin = user?.userType === 'NgoAdmin'; 32 | const isAdminNotOrganizer = isAdmin && !user?.organizer; 33 | const form = this.fb.group({ 34 | ngoId: [ 35 | { 36 | value: isAdminNotOrganizer ? user?.idNgo : '', 37 | disabled: isAdminNotOrganizer 38 | }, 39 | Validators.required 40 | ], 41 | file: ['', Validators.required], 42 | }); 43 | return form; 44 | } 45 | 46 | onSubmit () { 47 | const formData = new FormData(); 48 | formData.append('file', this.formGroup.get('file').value); 49 | formData.append('ongId', this.formGroup.get('ngoId').value); 50 | 51 | this.observerService.uploadCsv(formData).subscribe( 52 | (res) => { 53 | this.toastr.success( 54 | `${res} observers have been added successfully`, 55 | 'Success' 56 | ); 57 | }, 58 | (err) => { 59 | this.toastr.error('Encountered error while uploading csv', 'Error'); 60 | } 61 | ); 62 | } 63 | 64 | onFileChange(event) { 65 | if (event.target.files.length > 0) { 66 | const file = event.target.files[0]; 67 | this.formGroup.get('file').setValue(file); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/components/counties/counties.component.ts: -------------------------------------------------------------------------------- 1 | import { County } from '../../store/county/county.state'; 2 | import { AppState } from '../../store/store.module'; 3 | import { select, Store } from '@ngrx/store'; 4 | import { Component, Inject, OnInit } from '@angular/core'; 5 | import { map, take, tap, takeUntil } from 'rxjs/operators'; 6 | import { CountyPollingStationFetchAction } from 'src/app/store/county/county.actions'; 7 | import { Subject } from 'rxjs'; 8 | import { BASE_BUTTON_VARIANTS, Variants } from 'src/app/shared/base-button/base-button.component'; 9 | import { FormBuilder } from '@angular/forms'; 10 | 11 | @Component({ 12 | selector: 'app-counties', 13 | templateUrl: './counties.component.html', 14 | styleUrls: ['./counties.component.scss'], 15 | }) 16 | export class CountiesComponent implements OnInit { 17 | private destroy$: Subject = new Subject(); 18 | private countyList: County[] = []; 19 | public filteredCounties: County[] = []; 20 | 21 | public filtersForm = this.fb.group({ 22 | filter: '' 23 | }); 24 | 25 | 26 | constructor( 27 | private store: Store, 28 | private fb: FormBuilder, 29 | @Inject(BASE_BUTTON_VARIANTS) public BaseButtonVariants: typeof Variants) { } 30 | 31 | ngOnInit() { 32 | this.getList(); 33 | this.handleCountyData(); 34 | } 35 | 36 | ngOnDestroy() { 37 | this.destroy$.next(true); 38 | this.destroy$.unsubscribe(); 39 | } 40 | 41 | private getList() { 42 | this.store 43 | .pipe( 44 | select(s => s.county), 45 | take(1), 46 | map(_ => new CountyPollingStationFetchAction()) 47 | ) 48 | .subscribe(action => this.store.dispatch(action)); 49 | } 50 | 51 | private handleCountyData(): void { 52 | this.store.select(state => state.county).pipe( 53 | map(result => this.countyList = result?.counties ?? []), 54 | tap(countyList => this.filteredCounties = countyList), 55 | takeUntil(this.destroy$) 56 | ).subscribe(); 57 | } 58 | 59 | public filterList(text: string): void { 60 | const newFilter = [...this.countyList]; 61 | this.filteredCounties = newFilter.filter(item => 62 | item.name.toLocaleLowerCase().includes(text.toLowerCase()) || 63 | item.code.toLocaleLowerCase().includes(text.toLowerCase())) 64 | 65 | } 66 | 67 | public onResetFilters(): void { 68 | this.filteredCounties = [...this.countyList]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/services/forms.service.ts: -------------------------------------------------------------------------------- 1 | import { ApiService } from '../core/apiService/api.service'; 2 | import { Injectable } from '@angular/core'; 3 | import { FormInfo } from '../models/form.info.model'; 4 | import { Location } from '@angular/common'; 5 | import { Form } from '../models/form.model'; 6 | import { FormSection } from '../models/form.section.model'; 7 | import { environment } from 'src/environments/environment'; 8 | import { cloneDeep } from 'lodash'; 9 | import { HttpParams } from '@angular/common/http'; 10 | 11 | @Injectable() 12 | export class FormsService { 13 | private baseUrl: string; 14 | 15 | constructor(private http: ApiService) { 16 | this.baseUrl = Location.joinWithSlash(environment.apiUrl, '/api/v1/form'); 17 | } 18 | 19 | public loadForms(draft: boolean = false) { 20 | const url: string = Location.joinWithSlash(this.baseUrl, `?draft=${draft}`); 21 | return this.http.get(url).pipe(); 22 | } 23 | 24 | public searchForms(name: string, pageNo?: number, pageSize?: number) { 25 | // TODO: enable search forms after BE is implemented 26 | // let url: string = Location.joinWithSlash(this.baseUrl, `/api/v1/form/search?Description=${name}`); 27 | // 28 | // if (pageNo > 0 && pageSize > 0) { 29 | // url = Location.joinWithSlash(this.baseUrl, `/api/v1/form/search?Description=${name}&Page=${pageNo}&PageSize=${pageSize}`); 30 | // } 31 | return this.http.get(this.baseUrl).pipe(); 32 | } 33 | 34 | public getForm(formId: number) { 35 | const url: string = Location.joinWithSlash(this.baseUrl, `/${formId}`); 36 | return this.http.get(url); 37 | } 38 | 39 | public saveForm(form: Form) { 40 | const formClone = cloneDeep(form); 41 | formClone.draft = true; 42 | 43 | return this.uploadForm(formClone); 44 | } 45 | 46 | public saveAndPublishForm(form: Form) { 47 | const formClone = cloneDeep(form); 48 | formClone.draft = false; 49 | 50 | return this.uploadForm(formClone); 51 | } 52 | 53 | public updateForm(form: Form) { 54 | return this.http.put(this.baseUrl, form); 55 | } 56 | 57 | private uploadForm(form: Form) { 58 | if (!form.currentVersion) { 59 | form.currentVersion = 1; 60 | } 61 | 62 | return this.http.post(this.baseUrl, form); 63 | } 64 | 65 | public deleteForm(formId: number) { 66 | const params = new HttpParams({ fromObject: { formId: String(formId) } }); 67 | return this.http.delete(this.baseUrl, { params }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/app/components/forms/question/question.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
{{ questionFormGroup.controls['code']?.value || 'QUESTION_CODE' | translate }} - {{ questionFormGroup.controls['text']?.value || 'QUESTION_TEXT' | translate }}
5 |
6 | 7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | 23 |
24 |
25 |
26 |
27 |
28 |
29 | 32 | 33 | 34 |
35 | 36 |
37 |
38 |
39 |
40 |
+ {{'OPTION_ADD' | translate}}
41 |
+ {{'PREDEFINED_OPTIONS_MODAL.ADD' | translate}}
42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/app/components/answer/answer.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 | 7 |
8 |
9 | 10 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 |
34 | 35 |
36 |

{{((answerState | async)?.urgent ? 'URGENT_HEADER' : 'NOT_URGENT_HEADER') | translate}}

37 |
{{((answerState | async)?.urgent ? 'URGENT_SUBHEADER' : 'NOT_URGENT_SUBHEADER') | translate}}
38 | 39 |
40 |
41 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | -------------------------------------------------------------------------------- /src/app/store/county/county.actions.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Action } from "@ngrx/store"; 3 | import { actionType } from "../util"; 4 | import { County } from "./county.state"; 5 | 6 | 7 | export class CountyActionTypes { 8 | static readonly FETCH_COUNTIES_FROM_ANSWERS = actionType('[Answers Page] Fetch Counties'); 9 | static readonly FETCH_COUNTIES_SUCCESS = actionType('[County Effects] Counties Fetched Success'); 10 | static readonly FETCH_COUNTIES_FAILURE = actionType('[County Effects] Counties Fetched Failure'); 11 | static readonly FETCH_COUNTIES_FOR_POLLING_STATIONS = actionType('[Polling Station Page] Fetch Counties'); 12 | static readonly FETCH_COUNTIES_FOR_POLLING_STATIONS_SUCCESS = actionType('[Polling Station Effects] Counties Fetched Success'); 13 | static readonly FETCH_COUNTIES_FOR_POLLING_STATIONS_FAILURE = actionType('[Polling Station Effects] Counties Fetched Failure'); 14 | } 15 | 16 | export type CountyActions = 17 | CountyAnswersFetchAction | 18 | CountyAnswersErrorAction | 19 | CountyAnswersSuccessAction | 20 | CountyPollingStationFetchAction | 21 | CountyPollingStationErrorAction | 22 | CountyPollingStationSuccessAction; 23 | 24 | 25 | export class CountyAnswersFetchAction implements Action { 26 | readonly type = CountyActionTypes.FETCH_COUNTIES_FROM_ANSWERS; 27 | } 28 | 29 | export class CountyAnswersErrorAction implements Action { 30 | readonly type = CountyActionTypes.FETCH_COUNTIES_FAILURE; 31 | errorMessage: string; 32 | 33 | constructor(errorMessage: string) { 34 | this.errorMessage = errorMessage; 35 | } 36 | } 37 | 38 | export class CountyAnswersSuccessAction implements Action { 39 | readonly type = CountyActionTypes.FETCH_COUNTIES_SUCCESS; 40 | counties: County[]; 41 | 42 | constructor(counties: County[]) { 43 | this.counties = counties; 44 | } 45 | } 46 | 47 | 48 | export class CountyPollingStationFetchAction implements Action { 49 | readonly type = CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS; 50 | 51 | constructor() { } 52 | } 53 | 54 | export class CountyPollingStationErrorAction implements Action { 55 | readonly type = CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS_FAILURE; 56 | errorMessage: string; 57 | 58 | constructor(errorMessage: string) { 59 | this.errorMessage = errorMessage; 60 | } 61 | } 62 | 63 | export class CountyPollingStationSuccessAction implements Action { 64 | readonly type = CountyActionTypes.FETCH_COUNTIES_FOR_POLLING_STATIONS_SUCCESS; 65 | counties: County[]; 66 | 67 | constructor(counties: County[]) { 68 | this.counties = counties; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/services/notifications.service.ts: -------------------------------------------------------------------------------- 1 | import {first, take} from 'rxjs/operators'; 2 | import {Injectable} from '@angular/core'; 3 | import {ApiService, QueryParamBuilder} from '../core/apiService/api.service'; 4 | import {Observable} from 'rxjs'; 5 | import {HistoryNotifications, SentGlobalNotificationModel, SentNotificationModel,} from '../models/notification.model'; 6 | import {environment} from 'src/environments/environment'; 7 | import {Location} from '@angular/common'; 8 | import {Observer} from '../models/observer.model'; 9 | import {HttpParams} from '@angular/common/http'; 10 | 11 | @Injectable({ 12 | providedIn: 'root', 13 | }) 14 | export class NotificationsService { 15 | private readonly baseUrl: string; 16 | 17 | constructor(private http: ApiService) { 18 | this.baseUrl = environment.apiUrl; 19 | } 20 | 21 | public getAll(page, pageSize = 100): Observable { 22 | const url = Location.joinWithSlash(this.baseUrl, '/api/v1/notification/get/all'); 23 | const params = new HttpParams() 24 | .set('page', page.toString()) 25 | .set('pageSize', pageSize.toString()); 26 | return this.http.get(url, {params}).pipe(first()); 27 | } 28 | 29 | public pushNotification(notification: SentNotificationModel): Observable { 30 | const url: string = Location.joinWithSlash(this.baseUrl, '/api/v1/notification/send'); 31 | return this.http.post(url, notification).pipe(take(1)); 32 | } 33 | 34 | public pushNotificationGlobally(notification: SentGlobalNotificationModel): Observable { 35 | const url: string = Location.joinWithSlash(this.baseUrl, '/api/v1/notification/send/all'); 36 | return this.http.post(url, notification).pipe(take(1)); 37 | } 38 | 39 | public getCounties(): Observable { 40 | const url: string = Location.joinWithSlash( 41 | this.baseUrl, 42 | '/api/v1/polling-station' 43 | ); 44 | return this.http.get(url).pipe(take(1)); 45 | } 46 | 47 | public getActiveObserversInCounties( 48 | counties: string[] 49 | ): Observable { 50 | const urlWithParams = QueryParamBuilder.Instance('/api/v1/observer/active') 51 | .withParam('countyCodes', counties) 52 | .withParam('currentlyCheckedIn', true) 53 | .build(); 54 | 55 | const url: string = Location.joinWithSlash(this.baseUrl, urlWithParams); 56 | return this.http.get(url).pipe(take(1)); 57 | } 58 | } 59 | 60 | export interface CountyPollingStationInfo { 61 | id: number; 62 | name: string; 63 | code: string; 64 | limit: number; 65 | } 66 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /src/app/answers/answer-details/answer-details.component.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables'; 2 | 3 | .c-answer-header { 4 | &__title { 5 | color: $darker-gray; 6 | } 7 | } 8 | 9 | .c-answer-stats { 10 | background: #fff; 11 | box-shadow: 4px 4px 30px rgba(0, 0, 0, 0.08); 12 | border-radius: 4px; 13 | padding: 1.2rem 1.5rem; 14 | margin-top: 2rem; 15 | 16 | &__container { 17 | display: grid; 18 | width: 70%; 19 | grid-template-columns: 1fr 2fr; 20 | row-gap: 1rem; 21 | 22 | @media all and (max-width: 920px) { 23 | & { 24 | width: 100%; 25 | } 26 | } 27 | 28 | @media all and (max-width: 650px) { 29 | & { 30 | grid-template-columns: 1fr 1fr; 31 | } 32 | } 33 | 34 | @media all and (max-width: 380px) { 35 | & { 36 | grid-template-columns: 1fr; 37 | } 38 | } 39 | } 40 | 41 | &__label { 42 | color: $dim-gray; 43 | font-weight: bold; 44 | 45 | & + span { 46 | color: $readable-dark-gray; 47 | } 48 | } 49 | } 50 | 51 | .c-answer-forms { 52 | box-shadow: 4px 4px 30px rgba(0, 0, 0, 0.08); 53 | border-radius: 4px; 54 | background-color: #fff; 55 | margin-top: 2rem; 56 | margin-bottom: 2rem; 57 | padding: 1rem; 58 | display: grid; 59 | grid-template-rows: auto 1fr; 60 | overflow-y: auto; 61 | max-height: 80vh; 62 | 63 | &__tabs { 64 | display: flex; 65 | border-bottom: 1px solid $border-color-gray; 66 | padding-top: .5rem; 67 | column-gap: 1.5rem; 68 | } 69 | 70 | &__sections { 71 | margin-top: 1rem; 72 | } 73 | } 74 | 75 | .c-answer-form-tab { 76 | color: $dim-gray; 77 | padding-bottom: .7rem; 78 | font-size: 1.1rem; 79 | cursor: pointer; 80 | 81 | &.is-tab-selected { 82 | color: $primary-purple; 83 | border-bottom: 1px solid $primary-purple; 84 | } 85 | } 86 | 87 | .c-notes-modal { 88 | &__header { 89 | border-bottom: 1px solid $border-color-gray; 90 | padding: 1rem 1.4rem; 91 | color: $darker-gray; 92 | } 93 | 94 | &__content { 95 | color: $readable-dark-gray; 96 | padding: 1rem 1.2rem; 97 | 98 | p { 99 | margin: 0; 100 | } 101 | } 102 | 103 | &__close-button { 104 | display: block; 105 | width: max-content; 106 | margin-left: auto; 107 | margin-top: 2rem; 108 | } 109 | 110 | &__image-container { 111 | overflow: hidden; 112 | max-height: 45rem; 113 | margin-top: 2rem; 114 | 115 | img { 116 | max-height: 100%; 117 | max-width: 100%; 118 | } 119 | } 120 | 121 | &__video-container { 122 | max-height: 45rem; 123 | margin-top: 2rem; 124 | text-align: center; 125 | 126 | video { 127 | height: 45rem; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app/answers/answer-notification/answer-notification.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 6 |
7 | 9 | 10 | 11 | 12 |   13 | {{ 'BACK' | translate }} 14 |
15 |
16 |
17 |
18 | 19 |

{{ 'NOTIF_DETAILS_FOR' | translate }} {{ observerName$ | async }}

20 | 21 |
22 | {{ actionResultMessage ? (actionResultMessage | translate) : '' }} 23 |
24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 | 34 | 35 |
36 | 37 |
38 | 41 | 43 |
44 |
45 | 46 |
47 | 54 | {{ 'SAVE' | translate }} 55 | 56 | 57 | 64 | {{ 'CANCEL' | translate }} 65 | 66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------