diff --git a/epictrack-api/src/api/models/special_field.py b/epictrack-api/src/api/models/special_field.py index 71a7975c9..bb7460093 100644 --- a/epictrack-api/src/api/models/special_field.py +++ b/epictrack-api/src/api/models/special_field.py @@ -41,3 +41,16 @@ class SpecialField(BaseModelVersioned): time_range = Column(TSTZRANGE, nullable=False) __table_args__ = (Index('entity_field_index', "entity", "entity_id", "field_name", "time_range"), ) + + @classmethod + def find_by_params(cls, params: dict, default_filters=True): + """Returns based on the params""" + query = {} + for key, value in params.items(): + query[key] = value + if default_filters and hasattr(cls, 'is_active'): + query['is_active'] = True + if hasattr(cls, 'is_deleted'): + query['is_deleted'] = False + rows = cls.query.filter_by(**query).order_by(SpecialField.time_range.desc()).all() + return rows diff --git a/epictrack-api/src/api/resources/special_field.py b/epictrack-api/src/api/resources/special_field.py index f92af51a9..25861f924 100644 --- a/epictrack-api/src/api/resources/special_field.py +++ b/epictrack-api/src/api/resources/special_field.py @@ -22,8 +22,7 @@ from api.schemas import request as req from api.schemas import response as res from api.services.special_field import SpecialFieldService -from api.utils import auth, constants -from api.utils.caching import AppCache +from api.utils import auth from api.utils.profiler import profiletime from api.utils.util import cors_preflight @@ -40,7 +39,6 @@ class SpecialFields(Resource): @cors.crossdomain(origin="*") @auth.require @profiletime - @AppCache.cache.cached(timeout=constants.CACHE_DAY_TIMEOUT, query_string=True) def get(): """Return all special field values based on params.""" params = req.SpecialFieldQueryParamSchema().load(request.args) @@ -85,23 +83,3 @@ def put(special_field_id): request_json = req.SpecialFieldBodyParameterSchema().load(API.payload) special_field_entry = SpecialFieldService.update_special_field_entry(special_field_id, request_json) return res.SpecialFieldResponseSchema().dump(special_field_entry), HTTPStatus.OK - - -@cors_preflight('GET') -@API.route('/exists', methods=['GET', 'OPTIONS']) -class ValidateSpecialFieldEntry(Resource): - """Endpoint resource to check for existing special field entry.""" - - @staticmethod - @cors.crossdomain(origin='*') - @auth.require - @profiletime - def get(): - """Checks for existing special field entries.""" - args = req.SpecialFieldExistanceQueryParamSchema().load(request.args) - special_field_entry_id = args.pop('spcial_field_id') - exists = SpecialFieldService.check_existence(args, special_field_id=special_field_entry_id) - return ( - {'exists': exists}, - HTTPStatus.OK, - ) diff --git a/epictrack-api/src/api/schemas/request/__init__.py b/epictrack-api/src/api/schemas/request/__init__.py index 5c391fed5..99678bc2c 100644 --- a/epictrack-api/src/api/schemas/request/__init__.py +++ b/epictrack-api/src/api/schemas/request/__init__.py @@ -38,8 +38,7 @@ ProponentBodyParameterSchema, ProponentExistenceQueryParamSchema, ProponentIdPathParameterSchema) from .reminder_configuration_request import ReminderConfigurationExistenceQueryParamSchema from .special_field_request import ( - SpecialFieldBodyParameterSchema, SpecialFieldExistanceQueryParamSchema, SpecialFieldIdPathParameterSchema, - SpecialFieldQueryParamSchema) + SpecialFieldBodyParameterSchema, SpecialFieldIdPathParameterSchema, SpecialFieldQueryParamSchema) from .staff_request import ( StaffBodyParameterSchema, StaffByPositionsQueryParamSchema, StaffEmailPathParameterSchema, StaffExistanceQueryParamSchema, StaffIdPathParameterSchema) diff --git a/epictrack-api/src/api/schemas/request/special_field_request.py b/epictrack-api/src/api/schemas/request/special_field_request.py index e4eeb3660..414839815 100644 --- a/epictrack-api/src/api/schemas/request/special_field_request.py +++ b/epictrack-api/src/api/schemas/request/special_field_request.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Special field resource's input validations""" -from marshmallow import fields, validate +from marshmallow import EXCLUDE, fields, validate from api.models.special_field import EntityEnum @@ -71,11 +71,11 @@ class SpecialFieldBodyParameterSchema(RequestBodyParameterSchema): active_from = fields.DateTime( metadata={"description": "Lower bound for time range"}, required=True ) - active_to = fields.DateTime( - metadata={"description": "Upper bound for time range"}, - allow_none=True, - missing=None, - ) + + class Meta: # pylint: disable=too-few-public-methods + """Meta information""" + + unknown = EXCLUDE class SpecialFieldIdPathParameterSchema(RequestPathParameterSchema): @@ -86,49 +86,3 @@ class SpecialFieldIdPathParameterSchema(RequestPathParameterSchema): validate=validate.Range(min=1), required=True, ) - - -class SpecialFieldExistanceQueryParamSchema(RequestQueryParameterSchema): - """Special field existance check query parameters""" - - entity = fields.Str( - metadata={"description": "Entity name"}, - required=True, - validate=validate.OneOf([x.value for x in EntityEnum]), - ) - - entity_id = fields.Int( - metadata={"description": "The id of the entity"}, - validate=validate.Range(min=1), - required=True, - ) - - field_name = fields.Str( - metadata={"description": "Name of the special field"}, - validate=validate.Length(max=150), - required=True, - ) - - field_value = fields.Str( - metadata={"description": "Value of the special field"}, - validate=validate.Length(min=1), - required=True, - ) - - active_from = fields.DateTime( - metadata={"description": "Lower bound for time range"}, required=True - ) - - active_to = fields.DateTime( - metadata={"description": "Upper bound for time range"}, - allow_none=True, - missing=None, - ) - - spcial_field_id = fields.Int( - metadata={"description": "The id of the special field entry"}, - validate=validate.Range(min=1), - required=False, - allow_none=True, - missing=None - ) diff --git a/epictrack-api/src/api/services/project.py b/epictrack-api/src/api/services/project.py index d594f8fbd..deadde806 100644 --- a/epictrack-api/src/api/services/project.py +++ b/epictrack-api/src/api/services/project.py @@ -26,11 +26,13 @@ from api.models.project import ProjectStateEnum from api.models.proponent import Proponent from api.models.region import Region +from api.models.special_field import EntityEnum from api.models.sub_types import SubType from api.models.types import Type from api.models.work import Work from api.models.work_type import WorkType from api.schemas.types import TypeSchema +from api.services.special_field import SpecialFieldService from api.utils.constants import PROJECT_STATE_ENUM_MAPS from api.utils.enums import ProjectCodeMethod from api.utils.token_info import TokenInfo @@ -66,6 +68,23 @@ def create_project(cls, payload: dict): project = Project(**payload) project.project_state = ProjectStateEnum.PRE_WORK current_app.logger.info(f"Project obj {dir(project)}") + project.flush() + proponent_special_field_data = { + "entity": EntityEnum.PROJECT, + "entity_id": project.id, + "field_name": "proponent_id", + "field_value": project.proponent_id, + "active_from": project.created_at + } + SpecialFieldService.create_special_field_entry(proponent_special_field_data) + project_name_special_field_data = { + "entity": EntityEnum.PROJECT, + "entity_id": project.id, + "field_name": "name", + "field_value": project.name, + "active_from": project.created_at + } + SpecialFieldService.create_special_field_entry(project_name_special_field_data) project.save() return project diff --git a/epictrack-api/src/api/services/special_field.py b/epictrack-api/src/api/services/special_field.py index 4b46ae061..c976d8b75 100644 --- a/epictrack-api/src/api/services/special_field.py +++ b/epictrack-api/src/api/services/special_field.py @@ -12,11 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage Special fields.""" +from datetime import datetime, timedelta +from typing import Union + from flask import current_app from psycopg2.extras import DateTimeTZRange +from sqlalchemy import func, or_ +from sqlalchemy.dialects.postgresql.ranges import Range -from api.exceptions import ResourceNotFoundError, UnprocessableEntityError +from api.exceptions import ResourceNotFoundError from api.models import SpecialField, db +from api.utils.constants import SPECIAL_FIELD_ENTITY_MODEL_MAPS class SpecialFieldService: # pylint:disable=too-few-public-methods @@ -31,48 +37,77 @@ def find_all_by_params(cls, args: dict): @classmethod def create_special_field_entry(cls, payload: dict): """Create special field entry""" - # payload["time_range"] = DateTimeTZRange([payload["time_range"]] + ["[)"]) - existing = cls.check_existence(payload) - if existing: - raise UnprocessableEntityError("Value with overlapping time range exists. Please fix it before continuing.") + upper_limit = cls._get_upper_limit(payload) payload["time_range"] = DateTimeTZRange( - payload.pop("active_from"), payload.pop("active_to", None), "[)" + payload.pop("active_from"), upper_limit, bounds="[)" ) - entry = SpecialField(**payload) - entry.save() - return entry + special_field = SpecialField(**payload) + special_field.save() + cls._update_original_model(special_field) + db.session.commit() + return special_field @classmethod def update_special_field_entry(cls, special_field_id: int, payload: dict): """Create special field entry""" - exists = cls.check_existence(payload, special_field_id) - if exists: - raise UnprocessableEntityError("Value with overlapping time range exists. Please fix it before continuing.") special_field = SpecialField.find_by_id(special_field_id) + upper_limit = cls._get_upper_limit(payload, special_field_id) + if not special_field: - raise ResourceNotFoundError(f"Special field entry with id '{special_field_id}' not found") - payload["time_range"] = DateTimeTZRange( - payload.pop("active_from"), payload.pop("active_to", None), "[)" + raise ResourceNotFoundError( + f"Special field entry with id '{special_field_id}' not found" + ) + payload["time_range"] = Range( + payload.pop("active_from"), upper_limit, bounds="[)" ) special_field = special_field.update(payload) + cls._update_original_model(special_field) + db.session.commit() + return special_field + + @classmethod + def find_by_id(cls, _id): + """Find special field entry by id.""" + special_field = SpecialField.find_by_id(_id) return special_field @classmethod - def check_existence(cls, payload: dict, special_field_id: int = None) -> bool: - """Validate time range""" - new_range = DateTimeTZRange(payload["active_from"], payload["active_to"], "[)") + def _get_upper_limit( + cls, payload: dict, special_field_id: int = None + ) -> Union[datetime, None]: + """Finds and returns the upper limit of time range and updates existing entries to match new time range""" exists_query = db.session.query(SpecialField).filter( SpecialField.entity == payload["entity"], SpecialField.entity_id == payload["entity_id"], SpecialField.field_name == payload["field_name"], - SpecialField.time_range.overlaps(new_range), + or_( + SpecialField.time_range.contains(payload["active_from"]), + func.lower(SpecialField.time_range) > payload["active_from"], + ), ) if special_field_id: exists_query = exists_query.filter(SpecialField.id != special_field_id) - return bool(exists_query.first()) + existing_entry = exists_query.order_by(SpecialField.time_range.asc()).first() + upper_limit = None + if existing_entry: + if existing_entry.time_range.lower > payload["active_from"]: + upper_limit = existing_entry.time_range.lower + timedelta(days=-1) + else: + upper_limit = existing_entry.time_range.upper + new_range = DateTimeTZRange( + existing_entry.time_range.lower, + payload["active_from"] + timedelta(days=-1), + "[)", + ) + existing_entry.time_range = new_range + db.session.add(existing_entry) + return upper_limit @classmethod - def find_by_id(cls, _id): - """Find special field entry by id.""" - special_field = SpecialField.find_by_id(_id) - return special_field + def _update_original_model(cls, special_field_entry: SpecialField) -> None: + """If `special_field_entry` is latest, update original table with new value""" + if special_field_entry.time_range.upper is None: + model_class = SPECIAL_FIELD_ENTITY_MODEL_MAPS[special_field_entry.entity] + model_class.query.filter( + model_class.id == special_field_entry.entity_id + ).update({special_field_entry.field_name: special_field_entry.field_value}) diff --git a/epictrack-api/src/api/utils/constants.py b/epictrack-api/src/api/utils/constants.py index 4b82d16f4..75736c895 100644 --- a/epictrack-api/src/api/utils/constants.py +++ b/epictrack-api/src/api/utils/constants.py @@ -1,6 +1,9 @@ """File representing constants used in the application""" -from api.models.project import ProjectStateEnum +from api.models.project import Project, ProjectStateEnum +from api.models.proponent import Proponent +from api.models.special_field import EntityEnum +from api.models.work import Work SCHEMA_MAPS = { @@ -27,3 +30,9 @@ } PIP_LINK_URL_BASE = "https://apps.nrs.gov.bc.ca/int/fnp/FirstNationDetail.xhtml?name=" + +SPECIAL_FIELD_ENTITY_MODEL_MAPS = { + EntityEnum.PROJECT: Project, + EntityEnum.WORK: Work, + EntityEnum.PROPONENT: Proponent, +} diff --git a/epictrack-web/package-lock.json b/epictrack-web/package-lock.json index 8c6e04bd6..824c59b84 100644 --- a/epictrack-web/package-lock.json +++ b/epictrack-web/package-lock.json @@ -30,7 +30,7 @@ "draft-js": "^0.11.7", "json-2-csv": "^4.0.0", "keycloak-js": "^21.1.1", - "material-react-table": "^1.13.0", + "material-react-table": "^2.0.5", "moment": "^2.29.4", "moment-range": "^4.0.2", "notistack": "^3.0.1", @@ -39,6 +39,7 @@ "react-draft-wysiwyg": "^1.15.0", "react-hook-form": "^7.37.0", "react-if": "^4.1.5", + "react-imask": "^7.1.3", "react-redux": "^8.0.5", "react-router-dom": "^6.10.0", "react-scripts": "5.0.1", @@ -2145,6 +2146,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.5.tgz", + "integrity": "sha512-7+ziVclejQTLYhXl+Oi1f6gTGD1XDCeLa4R472TNGQxb08zbEJ0OdNoh5Piz+57Ltmui6xR88BXR4gS3/Toslw==", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -3999,18 +4012,19 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.0.0-beta.65", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.65.tgz", - "integrity": "sha512-Q21cUoE0C8Oyzy3RAMV+u4BuB+RwIf2/oQRCWksmIBp1PqLEtvXhAldh7v/wUt7WKEkislKDICZAvbYYs7EAyQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.1.tgz", + "integrity": "sha512-IFOFuRUTaiM/yibty9qQ9BfycQnYXIDHGP2+cU+0LrFFGNhVxCXSQnaY6wkX8uJVteFEBjUondX0Hmpp7TNcag==", "dependencies": { - "@tanstack/virtual-core": "3.0.0-beta.65" + "@tanstack/virtual-core": "3.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/@tanstack/table-core": { @@ -4026,9 +4040,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.0.0-beta.65", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.65.tgz", - "integrity": "sha512-ObP2pvXBdbivinr7BWDbGqYt4TK8wNzYsOWio+qBkDx5AJFuvqcdJxcCCYnv4dzVTe5ELA1MT4tkt8NB/tnEdA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz", + "integrity": "sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -9470,6 +9484,17 @@ "node": ">= 4" } }, + "node_modules/imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.1.3.tgz", + "integrity": "sha512-jZCqTI5Jgukhl2ff+znBQd8BiHOTlnFYCIgggzHYDdoJsHmSSWr1BaejcYBxsjy4ZIs8Rm0HhbOxQcobcdESRQ==", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.6" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -11436,29 +11461,30 @@ } }, "node_modules/material-react-table": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-1.15.1.tgz", - "integrity": "sha512-TXidRV7lGtCV5G/ON9Y38TztRcmpKFodFmyTCjvlKXCl5/9X+KY4waP8U0l16FFslg1f7HGWhfkqV5OfUfEIoA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-2.0.5.tgz", + "integrity": "sha512-axRrqa/2QQ+AO3SiJbOtSyemlHX0S03X+IXW72z344d3LT+u/jsKiAmdWMLTN8ARScYMAN5NgrArujiLEmftSQ==", "dependencies": { "@tanstack/match-sorter-utils": "8.8.4", "@tanstack/react-table": "8.10.7", - "@tanstack/react-virtual": "3.0.0-beta.65", + "@tanstack/react-virtual": "3.0.1", "highlight-words": "1.2.2" }, "engines": { - "node": ">=14" + "node": ">=16" }, "funding": { "type": "github", "url": "https://github.com/sponsors/kevinvandy" }, "peerDependencies": { - "@emotion/react": ">=11", - "@emotion/styled": ">=11", - "@mui/icons-material": ">=5", - "@mui/material": ">=5", - "react": ">=17.0", - "react-dom": ">=17.0" + "@emotion/react": ">=11.11", + "@emotion/styled": ">=11.11", + "@mui/icons-material": ">=5.11", + "@mui/material": ">=5.13", + "@mui/x-date-pickers": ">=6.15.0", + "react": ">=18.0", + "react-dom": ">=18.0" } }, "node_modules/mdn-data": { @@ -14032,6 +14058,21 @@ "react": "^16.x || ^17.x || ^18.x" } }, + "node_modules/react-imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.1.3.tgz", + "integrity": "sha512-anCnzdkqpDzNwe7ot76kQSvmnz4Sw7AW/QFjjLh3B87HVNv9e2oHC+1m9hQKSIui2Tqm7w68ooMgDFsCQlDMyg==", + "dependencies": { + "imask": "^7.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "npm": ">=4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -18848,6 +18889,15 @@ "regenerator-runtime": "^0.14.0" } }, + "@babel/runtime-corejs3": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.5.tgz", + "integrity": "sha512-7+ziVclejQTLYhXl+Oi1f6gTGD1XDCeLa4R472TNGQxb08zbEJ0OdNoh5Piz+57Ltmui6xR88BXR4gS3/Toslw==", + "requires": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + } + }, "@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -19976,11 +20026,11 @@ } }, "@tanstack/react-virtual": { - "version": "3.0.0-beta.65", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.65.tgz", - "integrity": "sha512-Q21cUoE0C8Oyzy3RAMV+u4BuB+RwIf2/oQRCWksmIBp1PqLEtvXhAldh7v/wUt7WKEkislKDICZAvbYYs7EAyQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.1.tgz", + "integrity": "sha512-IFOFuRUTaiM/yibty9qQ9BfycQnYXIDHGP2+cU+0LrFFGNhVxCXSQnaY6wkX8uJVteFEBjUondX0Hmpp7TNcag==", "requires": { - "@tanstack/virtual-core": "3.0.0-beta.65" + "@tanstack/virtual-core": "3.0.0" } }, "@tanstack/table-core": { @@ -19989,9 +20039,9 @@ "integrity": "sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw==" }, "@tanstack/virtual-core": { - "version": "3.0.0-beta.65", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.65.tgz", - "integrity": "sha512-ObP2pvXBdbivinr7BWDbGqYt4TK8wNzYsOWio+qBkDx5AJFuvqcdJxcCCYnv4dzVTe5ELA1MT4tkt8NB/tnEdA==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz", + "integrity": "sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==" }, "@testing-library/dom": { "version": "9.3.3", @@ -24070,6 +24120,14 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==" }, + "imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.1.3.tgz", + "integrity": "sha512-jZCqTI5Jgukhl2ff+znBQd8BiHOTlnFYCIgggzHYDdoJsHmSSWr1BaejcYBxsjy4ZIs8Rm0HhbOxQcobcdESRQ==", + "requires": { + "@babel/runtime-corejs3": "^7.22.6" + } + }, "immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -25525,13 +25583,13 @@ } }, "material-react-table": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-1.15.1.tgz", - "integrity": "sha512-TXidRV7lGtCV5G/ON9Y38TztRcmpKFodFmyTCjvlKXCl5/9X+KY4waP8U0l16FFslg1f7HGWhfkqV5OfUfEIoA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-2.0.5.tgz", + "integrity": "sha512-axRrqa/2QQ+AO3SiJbOtSyemlHX0S03X+IXW72z344d3LT+u/jsKiAmdWMLTN8ARScYMAN5NgrArujiLEmftSQ==", "requires": { "@tanstack/match-sorter-utils": "8.8.4", "@tanstack/react-table": "8.10.7", - "@tanstack/react-virtual": "3.0.0-beta.65", + "@tanstack/react-virtual": "3.0.1", "highlight-words": "1.2.2" } }, @@ -27193,6 +27251,15 @@ "integrity": "sha512-Uk+Ub2gC83PAakuU4+7iLdTEP4LPi2ihNEPCtz/vr8SLGbzkMApbpYbkDZ5z9zYXurd0gg+EK/bpOLFFC1r1eQ==", "requires": {} }, + "react-imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.1.3.tgz", + "integrity": "sha512-anCnzdkqpDzNwe7ot76kQSvmnz4Sw7AW/QFjjLh3B87HVNv9e2oHC+1m9hQKSIui2Tqm7w68ooMgDFsCQlDMyg==", + "requires": { + "imask": "^7.1.3", + "prop-types": "^15.8.1" + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/epictrack-web/package.json b/epictrack-web/package.json index ee7fbf4fe..b8c0d5d2a 100644 --- a/epictrack-web/package.json +++ b/epictrack-web/package.json @@ -25,7 +25,7 @@ "draft-js": "^0.11.7", "json-2-csv": "^4.0.0", "keycloak-js": "^21.1.1", - "material-react-table": "^1.13.0", + "material-react-table": "^2.0.5", "moment": "^2.29.4", "moment-range": "^4.0.2", "notistack": "^3.0.1", diff --git a/epictrack-web/src/components/icons/index.tsx b/epictrack-web/src/components/icons/index.tsx index 89566a9c7..16b24f861 100644 --- a/epictrack-web/src/components/icons/index.tsx +++ b/epictrack-web/src/components/icons/index.tsx @@ -375,6 +375,46 @@ const ClockIcon = (props: IconProps) => { ); }; +const AddIcon = (props: IconProps) => { + return ( + + + + ); +}; + +const CheckIcon = (props: IconProps) => { + return ( + + + + ); +}; + +const CloseXIcon = (props: IconProps) => { + return ( + + + + ); +}; + +const LockClosedIcon = (props: IconProps) => { + return ( + + + + ); +}; + +const LockOpenIcon = (props: IconProps) => { + return ( + + + + ); +}; + const icons: { [x: string]: React.FC } = { AllIcon, DashboardIcon, @@ -413,6 +453,11 @@ const icons: { [x: string]: React.FC } = { DotIcon, ClockIcon, EyeIcon, + AddIcon, + CheckIcon, + CloseXIcon, + LockClosedIcon, + LockOpenIcon, }; export default icons; diff --git a/epictrack-web/src/components/project/ProjectForm.tsx b/epictrack-web/src/components/project/ProjectForm.tsx index f57cc881c..5829c7d24 100644 --- a/epictrack-web/src/components/project/ProjectForm.tsx +++ b/epictrack-web/src/components/project/ProjectForm.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Grid, Box } from "@mui/material"; +import { Grid, Box, IconButton } from "@mui/material"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -14,10 +14,20 @@ import subTypeService from "../../services/subTypeService"; import ControlledSelectV2 from "../shared/controlledInputComponents/ControlledSelectV2"; import { MasterContext } from "../shared/MasterContext"; import projectService from "../../services/projectService/projectService"; -import LockClosed from "../../assets/images/lock-closed.svg"; import ControlledSwitch from "../shared/controlledInputComponents/ControlledSwitch"; import { Palette } from "../../styles/theme"; import ControlledTextField from "../shared/controlledInputComponents/ControlledTextField"; +import { SpecialFieldGrid } from "../shared/specialField"; +import { + SpecialFieldEntityEnum, + SpecialFields, +} from "../../constants/application-constant"; +import { Else, If, Then, When } from "react-if"; +import Icons from "../icons"; +import { IconProps } from "../icons/type"; + +const LockClosedIcon: React.FC = Icons["LockClosedIcon"]; +const LockOpenIcon: React.FC = Icons["LockOpenIcon"]; const schema = yup.object().shape({ name: yup @@ -64,6 +74,8 @@ export default function ProjectForm({ ...props }) { const [types, setTypes] = React.useState([]); const [proponents, setProponents] = React.useState(); const [disabled, setDisabled] = React.useState(); + const [specialField, setSpecialField] = React.useState(""); + const ctx = React.useContext(MasterContext); React.useEffect(() => { @@ -214,17 +226,24 @@ export default function ProjectForm({ ...props }) { }} > Name - - - + + + + setSpecialField("")}> + + + + + + setSpecialField(SpecialFields.PROJECT.NAME) + } + > + + + + + Proponent - - - + + + + setSpecialField("")}> + + + + + + setSpecialField(SpecialFields.PROJECT.PROPONENT) + } + > + + + + + + + + + + Update the Proponent of this Project.{" "} + Click this link for detailed + instructions. + + + Update the legal name of the Project and the dates each + name was in legal use. Click this link{" "} + for detailed instructions + + + } + options={ + specialField === SpecialFields.PROJECT.PROPONENT + ? proponents?.map((p) => ({ + label: p.name, + value: p.id.toString(), + })) || [] + : [] + } + onSave={() => { + // TODO: Refresh form field value for the specific field? + // OR do we just call form save/submit handler + ctx.setId(props.projectId); + ctx.getData(); + }} + /> + + Type - + )} data={rfData} diff --git a/epictrack-web/src/components/shared/MasterTrackTable/index.tsx b/epictrack-web/src/components/shared/MasterTrackTable/index.tsx index 92aedb288..65335773b 100644 --- a/epictrack-web/src/components/shared/MasterTrackTable/index.tsx +++ b/epictrack-web/src/components/shared/MasterTrackTable/index.tsx @@ -1,6 +1,10 @@ import React from "react"; -import MaterialReactTable, { - MaterialReactTableProps, +import { + MaterialReactTable, + MRT_ColumnDef, + MRT_RowData, + MRT_TableOptions, + useMaterialReactTable, } from "material-react-table"; import { Box, Container, Typography } from "@mui/material"; import SearchIcon from "../../../assets/images/search.svg"; @@ -49,161 +53,167 @@ const NoDataComponent = ({ ...props }) => { ); }; -const MasterTrackTable = >({ + +export interface MaterialReactTableProps + extends MRT_TableOptions { + columns: MRT_ColumnDef[]; + data: TData[]; +} + +const MasterTrackTable = ({ columns, data, ...rest -}: MaterialReactTableProps) => { - return ( - <> - ({ - disabled: true, - sx: { - padding: "0.5rem 0.5rem 0.5rem 1rem", - "& .MuiCheckbox-root": { - width: "2.75rem !important", - height: "2rem", - borderRadius: "4px", - padding: "8px !important", - "&.Mui-disabled": { - svg: { - fill: Palette.neutral.light, - }, - }, - }, - }, - })} - muiTableHeadCellFilterTextFieldProps={({ column }) => ({ - placeholder: column.columnDef.header, - variant: "outlined", - sx: { - backgroundColor: "white", - "& .MuiInputBase-input::placeholder": { - color: Palette.neutral.light, - fontSize: "0.875rem", - lineHeight: "1rem", - opacity: 1, - }, - "& .MuiInputAdornment-root": { - display: "none", - }, - "& .MuiSelect-icon": { - mr: "0px !important", +}: MaterialReactTableProps) => { + const table = useMaterialReactTable({ + columns, + data, + globalFilterFn: "contains", + enableHiding: false, + enableGlobalFilter: false, + enableStickyHeader: true, + enableDensityToggle: false, + enableColumnFilters: true, + enableFullScreenToggle: false, + enableSorting: true, + enableFilters: true, + enableColumnActions: false, + enablePinning: true, + enablePagination: false, + positionActionsColumn: "last", + muiTableHeadProps: { + sx: { + "& .MuiTableRow-root": { + boxShadow: "none", + }, + }, + }, + muiTableHeadCellProps: { + sx: { + backgroundColor: Palette.neutral.bg.light, + padding: "1rem 0.5rem 0.5rem 1rem !important", + "& .Mui-TableHeadCell-Content-Labels": { + fontSize: "1rem", + fontWeight: MET_Header_Font_Weight_Bold, + color: Palette.neutral.dark, + paddingBottom: "0.5rem", + }, + "& .MuiTextField-root": { + minWidth: "0", + }, + "& .MuiCheckbox-root": { + width: "2.75rem !important", + height: "2rem", + padding: "8px !important", + borderRadius: "4px", + }, + }, + }, + muiTableProps: { + sx: { + tableLayout: "fixed", + }, + }, + muiTableBodyCellProps: ({ row }) => ({ + disabled: true, + sx: { + padding: "0.5rem 0.5rem 0.5rem 1rem", + "& .MuiCheckbox-root": { + width: "2.75rem !important", + height: "2rem", + borderRadius: "4px", + padding: "8px !important", + "&.Mui-disabled": { + svg: { + fill: Palette.neutral.light, }, }, - })} - muiTableContainerProps={(table) => ({ - sx: { - maxHeight: "100%", - }, - })} - muiTableBodyProps={{ - sx: { - "& tr:hover td": { - backgroundColor: Palette.primary.bg.light, - }, - }, - }} - muiTableBodyRowProps={{ - hover: true, - sx: { - "&.Mui-selected": { - backgroundColor: Palette.primary.bg.main, - }, - "&.MuiTableRow-hover:hover": { - backgroundColor: Palette.primary.bg.light, - }, - }, - }} - sortingFns={{ - sortFn: (rowA: any, rowB: any, columnId: string) => { - return rowA - ?.getValue(columnId) - ?.localeCompare(rowB?.getValue(columnId), "en", { - numeric: true, - ignorePunctuation: false, - sensitivity: "base", - }); - }, - }} - renderToolbarInternalActions={({ table }) => ( - <>{/* */} - )} - renderEmptyRowsFallback={({ table }) => ( - - )} - {...rest} - initialState={{ - showColumnFilters: true, - density: "compact", - columnPinning: { right: ["mrt-row-actions"] }, - ...rest.initialState, - }} - state={{ - showGlobalFilter: true, - columnPinning: { right: ["mrt-row-actions"] }, - ...rest.state, - }} - icons={{ - FilterAltIcon: () => null, - CloseIcon: () => null, - }} - filterFns={{ - multiSelectFilter: (row, id, filterValue) => { - if (filterValue.length === 0) return true; - return filterValue.includes(row.getValue(id)); - }, - }} - /> + }, + }, + }), + muiFilterTextFieldProps: ({ column }) => ({ + placeholder: column.columnDef.header, + variant: "outlined", + sx: { + backgroundColor: "white", + "& .MuiInputBase-input::placeholder": { + color: Palette.neutral.light, + fontSize: "0.875rem", + lineHeight: "1rem", + opacity: 1, + }, + "& .MuiInputAdornment-root": { + display: "none", + }, + "& .MuiSelect-icon": { + mr: "0px !important", + }, + }, + }), + muiTableContainerProps: (table) => ({ + sx: { + maxHeight: "100%", + }, + }), + muiTableBodyProps: { + sx: { + "& tr:hover td": { + backgroundColor: Palette.primary.bg.light, + }, + }, + }, + muiTableBodyRowProps: { + hover: true, + sx: { + "&.Mui-selected": { + backgroundColor: Palette.primary.bg.main, + }, + "&.MuiTableRow-hover:hover": { + backgroundColor: Palette.primary.bg.light, + }, + }, + }, + sortingFns: { + sortFn: (rowA: any, rowB: any, columnId: string) => { + return rowA + ?.getValue(columnId) + ?.localeCompare(rowB?.getValue(columnId), "en", { + numeric: true, + ignorePunctuation: false, + sensitivity: "base", + }); + }, + }, + renderToolbarInternalActions: ({ table }) => ( + <>{/* */} + ), + renderEmptyRowsFallback: ({ table }) => , + ...rest, + initialState: { + showColumnFilters: true, + density: "compact", + columnPinning: { right: ["mrt-row-actions"] }, + ...rest.initialState, + }, + state: { + showGlobalFilter: true, + columnPinning: { right: ["mrt-row-actions"] }, + ...rest.state, + }, + icons: { + FilterAltIcon: () => null, + CloseIcon: () => null, + }, + filterFns: { + multiSelectFilter: (row, id, filterValue) => { + if (filterValue.length === 0) return true; + return filterValue.includes(row.getValue(id)); + }, + }, + }); + return ( + <> + ); }; diff --git a/epictrack-web/src/components/shared/specialField/components/InLineDatePicker.tsx b/epictrack-web/src/components/shared/specialField/components/InLineDatePicker.tsx new file mode 100644 index 000000000..756570ed3 --- /dev/null +++ b/epictrack-web/src/components/shared/specialField/components/InLineDatePicker.tsx @@ -0,0 +1,62 @@ +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs from "dayjs"; +import { + MRT_Cell, + MRT_Column, + MRT_Row, + MRT_TableInstance, +} from "material-react-table"; +import React from "react"; +import { SpecialField } from "../type"; + +interface Props { + cell: MRT_Cell; + column: MRT_Column; + row: MRT_Row; + table: MRT_TableInstance; + isCreating: boolean; + name: string; +} + +export const InLineDatePicker = ({ + cell, + column, + row, + table, + isCreating, + name, +}: Props) => { + const onBlur = (newValue: any) => { + row._valuesCache[column.id] = newValue; + if (isCreating) { + table.setCreatingRow(row); + } else { + table.setEditingRow(row); + } + }; + + const value = dayjs(row._valuesCache[column.id]); + + return ( + + + + ); +}; diff --git a/epictrack-web/src/components/shared/specialField/index.tsx b/epictrack-web/src/components/shared/specialField/index.tsx new file mode 100644 index 000000000..a4999c054 --- /dev/null +++ b/epictrack-web/src/components/shared/specialField/index.tsx @@ -0,0 +1,313 @@ +import { Box, Button, IconButton, TextField } from "@mui/material"; +import React, { useEffect, useMemo, useState } from "react"; +import { When } from "react-if"; +import { + MRT_ColumnDef, + MRT_TableOptions, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import ReactSelect, { CSSObjectWithLabel } from "react-select"; +import { Palette } from "../../../styles/theme"; +import { ETCaption2, ETCaption3, ETParagraph } from ".."; +import Icons from "../../icons"; +import { IconProps } from "../../icons/type"; +import { SpecialField, SpecialFieldProps } from "./type"; +import specialFieldService from "../../../services/specialFieldService"; +import { dateUtils } from "../../../utils"; +import { InLineDatePicker } from "./components/InLineDatePicker"; + +const AddIcon: React.FC = Icons["AddIcon"]; +const EditIcon: React.FC = Icons["PencilEditIcon"]; +const CheckIcon: React.FC = Icons["CheckIcon"]; +const CancelIcon: React.FC = Icons["CloseXIcon"]; + +const Styles = { + flexStart: { + display: "flex", + alignItems: "flex-start", + }, +}; + +export const SpecialFieldGrid = ({ + entity, + entity_id, + fieldLabel, + fieldName, + fieldType, + title, + description, + options, + onSave, +}: SpecialFieldProps) => { + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + + const getEntries = async () => { + setLoading(true); + const specialFieldEntries = await specialFieldService.getEntries( + entity, + entity_id, + fieldName + ); + if (specialFieldEntries.status === 200) { + setEntries(specialFieldEntries.data as SpecialField[]); + } + setLoading(false); + }; + + useEffect(() => { + getEntries(); + }, [fieldName]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "field_value", + header: fieldLabel, + editVariant: fieldType, + editSelectOptions: options, + size: 300, + Cell: ({ cell }) => { + const id = cell.getValue(); + let value; + if (fieldType === "select") { + const option = options?.find((o) => id.toString() === o.value); + value = option?.label; + } else { + value = id; + } + return {value}; + }, + muiEditTextFieldProps: ({ cell, column, row, table }) => { + return { + required: true, + InputProps: { + sx: { fontSize: "14px" }, + }, + }; + }, + Edit: ({ cell, column, row, table }) => { + let value: any = cell.getValue(); + if (fieldType === "select") { + value = options?.find((o) => row.original.field_value == o.value); + } + const onBlur = (newValue: any) => { + debugger; + row._valuesCache[column.id] = + fieldType === "select" ? newValue.value : newValue.target.value; + if (Boolean(tableState.creatingRow)) { + table.setCreatingRow(row); + } else { + table.setEditingRow(row); + } + }; + + return ( + <> + + { + return; + }} + menuPortalTarget={document.body} + name={fieldName} + styles={{ + container: (base: CSSObjectWithLabel) => ({ + ...base, + maxWidth: "284px", // 300 - padding of 16px + }), + menuPortal: (base: CSSObjectWithLabel) => ({ + ...base, + zIndex: 99999, + fontSize: "14px", + }), + }} + defaultValue={value || ""} + onChange={onBlur} + /> + + + + + + ); + }, + }, + { + accessorKey: "active_from", + header: "From", + editVariant: "text", + size: 170, + Edit: ({ cell, column, row, table }) => { + return ( + + ); + }, + Cell: ({ cell }) => { + const value = cell.getValue(); + return {dateUtils.formatDate(value)}; + }, + }, + { + accessorKey: "active_to", + header: "To", + size: 170, + enableEditing: false, + Cell: ({ cell }) => { + const value = cell.getValue(); + return {value ? dateUtils.formatDate(value) : "Today"}; + }, + }, + ], + [fieldLabel, fieldName, options] + ); + + const handleEditRowSave: MRT_TableOptions["onEditingRowSave"] = + async ({ values, table, row }) => { + await saveEntry(values, row.original.id); + table.setEditingRow(null); //exit editing mode + }; + + const handleCreateRowSave: MRT_TableOptions["onCreatingRowSave"] = + async ({ values, table }) => { + debugger; + await saveEntry(values); + table.setCreatingRow(null); //exit creating mode + }; + + const saveEntry = async ( + payload: SpecialField, + objectId: number | undefined = undefined + ) => { + const data = { + ...payload, + entity, + entity_id, + field_name: fieldName, + }; + + if (objectId) { + await specialFieldService.updateSpecialFieldEntry(data, objectId); + } else { + await specialFieldService.createSpecialFieldEntry(data); + } + getEntries(); + if (onSave) { + onSave(); + } + }; + + const table = useMaterialReactTable({ + columns: columns, + data: entries, + state: { + isLoading: loading, + }, + enableSorting: false, + editDisplayMode: "row", + createDisplayMode: "row", + enableFilters: false, + enableGlobalFilter: false, + enableEditing: true, + positionActionsColumn: "last", + enableHiding: false, + enableStickyHeader: true, + enableDensityToggle: false, + enableColumnFilters: true, + enableFullScreenToggle: false, + enableColumnActions: false, + enablePinning: false, + enablePagination: false, + getRowId: (originalRow) => originalRow.id?.toString() || "", + renderTopToolbarCustomActions: ({ table }) => ( + + ), + muiTableBodyCellProps: { + sx: { + fontSize: "14px", + }, + }, + muiTableHeadCellProps: { + sx: { + fontSize: "14px", + }, + }, + onEditingRowSave: handleEditRowSave, + onCreatingRowSave: handleCreateRowSave, + renderRowActions: ({ row, table }) => ( + { + table.setEditingRow(row); + }} + > + + + ), + icons: { + SaveIcon: (props: any) => ( + + ), + CancelIcon: (props: any) => ( + + ), + }, + }); + + const tableState = table.getState(); + + return ( + + + + {title} + + {description} + + + + + + ); +}; diff --git a/epictrack-web/src/components/shared/specialField/type.ts b/epictrack-web/src/components/shared/specialField/type.ts new file mode 100644 index 000000000..0d60ee387 --- /dev/null +++ b/epictrack-web/src/components/shared/specialField/type.ts @@ -0,0 +1,38 @@ +import { SpecialFieldEntityEnum } from "../../../constants/application-constant"; +import { OptionType } from "../filterSelect/type"; + +export interface EditSelectOptionType { + text: string; + value: any; +} +export interface SpecialFieldBaseProps { + entity: SpecialFieldEntityEnum; + title: string; + description: React.ReactElement; + entity_id: number; + fieldLabel: string; // Display label + fieldName: string; // Name in API requests + onSave?: () => any; +} + +interface SpecialFieldTextProps extends SpecialFieldBaseProps { + fieldType: "text"; + options?: OptionType[]; +} + +interface SpecialFieldSelectProps extends SpecialFieldBaseProps { + fieldType: "select"; + options: OptionType[]; +} + +export type SpecialFieldProps = SpecialFieldTextProps | SpecialFieldSelectProps; + +export interface SpecialField { + id?: number; + entity: SpecialFieldEntityEnum; + entity_id: number; + field_name: string; + field_value: string; + active_from: string; + active_to: string; +} diff --git a/epictrack-web/src/components/user/UserList.tsx b/epictrack-web/src/components/user/UserList.tsx index e199f3495..c98de41a1 100644 --- a/epictrack-web/src/components/user/UserList.tsx +++ b/epictrack-web/src/components/user/UserList.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; -import MaterialReactTable, { - type MaterialReactTableProps, +import { + MaterialReactTable, type MRT_ColumnDef, type MRT_Row, } from "material-react-table"; @@ -18,7 +18,9 @@ import { RESULT_STATUS } from "../../constants/application-constant"; import UserService from "../../services/userService"; import { ETPageContainer } from "../shared"; import Select from "react-select"; -import MasterTrackTable from "../shared/MasterTrackTable"; +import MasterTrackTable, { + MaterialReactTableProps, +} from "../shared/MasterTrackTable"; import { UserGroupUpdate } from "../../services/userService/type"; import { useAppSelector } from "../../hooks"; @@ -159,7 +161,7 @@ const UserList = () => { { diff --git a/epictrack-web/src/constants/api-endpoint.ts b/epictrack-web/src/constants/api-endpoint.ts index de0e2fb1b..e39168965 100644 --- a/epictrack-web/src/constants/api-endpoint.ts +++ b/epictrack-web/src/constants/api-endpoint.ts @@ -99,5 +99,9 @@ const Endpoints = { Position: { GET_ALL: "positions", }, + SpecialFields: { + SPECIAL_FIELDS: "/special-fields", + UPDATE: "/special-fields/:specialFieldId", + }, }; export default Endpoints; diff --git a/epictrack-web/src/constants/application-constant.ts b/epictrack-web/src/constants/application-constant.ts index 1f71a2fd0..25ae18f86 100644 --- a/epictrack-web/src/constants/application-constant.ts +++ b/epictrack-web/src/constants/application-constant.ts @@ -89,3 +89,16 @@ export const ROLES = { MANAGE_USERS: "manage_users", EXTENDED_EDIT: "extended_edit", }; + +export enum SpecialFieldEntityEnum { + PROJECT = "PROJECT", + PROPONENT = "PROPONENT", + WORK = "WORK", +} + +export const SpecialFields = { + PROJECT: { + NAME: "name", + PROPONENT: "proponent_id", + }, +}; diff --git a/epictrack-web/src/services/specialFieldService/index.ts b/epictrack-web/src/services/specialFieldService/index.ts new file mode 100644 index 000000000..1c06a885b --- /dev/null +++ b/epictrack-web/src/services/specialFieldService/index.ts @@ -0,0 +1,37 @@ +import Endpoints from "../../constants/api-endpoint"; +import http from "../../apiManager/http-request-handler"; +import { SpecialFieldEntityEnum } from "../../constants/application-constant"; +import { SpecialField } from "../../components/shared/specialField/type"; + +class SpecialFieldService { + getEntries = async ( + entity: SpecialFieldEntityEnum, + entity_id: number, + field_name: string + ) => { + return await http.GetRequest(Endpoints.SpecialFields.SPECIAL_FIELDS, { + entity, + entity_id, + field_name, + }); + }; + + createSpecialFieldEntry = async (payload: SpecialField) => { + return await http.PostRequest( + Endpoints.SpecialFields.SPECIAL_FIELDS, + payload + ); + }; + + updateSpecialFieldEntry = async (payload: SpecialField, objectId: number) => { + return await http.PutRequest( + Endpoints.SpecialFields.UPDATE.replace( + ":specialFieldId", + objectId.toString() + ), + payload + ); + }; +} + +export default new SpecialFieldService();