Compare commits

..

103 Commits

Author SHA1 Message Date
Andy Chan
53304b99ac
Merge 51c40684fa into cb54c9829d 2024-10-22 00:32:20 -04:00
Andy Chan
51c40684fa
Merge branch 'main' into banish-ds_store 2024-10-22 00:32:17 -04:00
pmoharana-cmd
cb54c9829d Remove angular cli dependency 2024-10-15 19:28:32 -04:00
pmoharana-cmd
a43fb7429a Add start script 2024-09-27 19:43:00 -04:00
pmoharana-cmd
70906fa231 Update README.md for dev container 2024-09-21 16:04:27 -04:00
pmoharana-cmd
f9abc9169f Dependency bumps + new fastAPI CLI 2024-09-18 15:48:33 -04:00
Prajwal Moharana
38091128bf
Merge pull request #37 from cssgunc/backend-dockerfile-update
Add node to dockerfile
2024-04-25 15:33:01 -04:00
pmoharana-cmd
67dbf0623f Add node to dockerfile 2024-04-25 15:32:31 -04:00
Prajwal Moharana
ef42478c54
Merge pull request #36 from cssgunc/backend-frontend-integration
Backend frontend integration
2024-04-24 21:27:58 -04:00
pmoharana-cmd
6d477678a9 Demo ready 2024-04-24 21:23:43 -04:00
pmoharana-cmd
ba15bf7519 Implement resource api methods 2024-04-24 20:18:09 -04:00
Sushant Marella
4e090e5bd5 Update methods so that it checks for users' role and group directly in each method 2024-04-24 20:01:26 -04:00
Sushant Marella
6cc0d697d4 Modify methods to filter resources based on user access 2024-04-24 20:01:19 -04:00
blake hardee
e8068ff2ea resource service and permissions service wip 2024-04-24 20:01:13 -04:00
blake hardee
31b714e70d resource service methods wip 2024-04-24 20:01:05 -04:00
pmoharana-cmd
3fc8f0e149 Implement service api method 2024-04-24 19:57:44 -04:00
CheezyGarlicBread
34dd4ec48f bug fixes 2024-04-24 19:38:22 -04:00
CheezyGarlicBread
0ad8b5059d finally made tests work, added get_by_name 2024-04-24 19:38:15 -04:00
CheezyGarlicBread
bb39dd6686 adding changes 2024-04-24 19:38:10 -04:00
CheezyGarlicBread
c3385b0f0f changes to tests and services 2024-04-24 19:38:00 -04:00
pmoharana-cmd
f4f816b94c Fix bugs regarding service test data insertion 2024-04-24 19:37:58 -04:00
Aidan Kim
a107337737 updated the service methods and modified a few of the tests. 2024-04-24 19:37:29 -04:00
pmoharana-cmd
dfed92816d Intialize resource, service, and training-manual routes 2024-04-24 19:36:28 -04:00
pmoharana-cmd
150dde1b0a Restructure data 2024-04-24 17:54:16 -04:00
pmoharana-cmd
f0fabead01 Add loading animations and authentication handling 2024-04-24 17:24:54 -04:00
Meliora Ho
ea547026cf Completely forgot what I did 2024-04-24 01:29:34 +00:00
pmoharana-cmd
e13100e0f7 Reroute from root route to login page 2024-04-23 21:27:18 -04:00
pmoharana-cmd
a979d6b051 Fix enum bugs and sign out issues 2024-04-23 21:21:55 -04:00
pmoharana-cmd
b0ced6ef9f Merge branch 'admin-GEN-57-all-together-now' into backend-frontend-integration 2024-04-23 21:17:18 -04:00
pmoharana-cmd
edbb1565e8 Save current change currents 2024-04-23 21:06:12 -04:00
pmoharana-cmd
bdf892c6c2 Add basic route handlers and endpoints 2024-04-23 21:06:00 -04:00
pmoharana-cmd
755d1523d0 Add basic authentication and redirection handling 2024-04-23 21:05:49 -04:00
pmoharana-cmd
7dc5aca9ee Init files for authentication 2024-04-23 21:05:41 -04:00
pmoharana-cmd
23e7ee7b9f Add husky commit hook for linting and formatting 2024-04-23 21:05:30 -04:00
pmoharana-cmd
f2765ecb47 Add prettier and reformat project 2024-04-23 21:05:09 -04:00
pmoharana-cmd
3fd0a545e7 Basic api handling 2024-04-23 21:05:01 -04:00
pmoharana-cmd
c71d5d4026 Intialize API with basic user methods 2024-04-23 21:04:48 -04:00
Meliora Ho
f9a6e71484 Edited the UI 2024-04-23 23:26:16 +00:00
Meliora Ho
38d640edc9 Merge remote-tracking branch 'origin/Ilakkiya-admin-GEN-103-filter' into admin-GEN-57-all-together-now 2024-04-23 22:55:40 +00:00
Meliora Ho
601ad5ce4f Merge remote-tracking branch 'origin/varun-admin-GEN-59-page' into admin-GEN-57-all-together-now 2024-04-23 22:55:06 +00:00
Varun Murlidhar
f56e383798 Add tag fields to drawer, style drawer 2024-04-23 18:29:23 -04:00
Varun Murlidhar
9e4ba5b433 Merge branch 'varun-admin-GEN-59-page' of https://github.com/cssgunc/compass into varun-admin-GEN-59-page 2024-04-21 06:51:37 -04:00
Ilakkiya Senthilkumar
a8e74f13eb 4/20 meeting progress 2024-04-20 15:30:48 -04:00
Ilakkiya Senthilkumar
80b450fa60 4/20 meeting progress 2024-04-20 15:30:37 -04:00
Andy Chan
e89b00b4b2
Merge pull request #33 from cssgunc/varun-admin-GEN-59-page
i love git so much
2024-04-20 15:04:49 -04:00
Andy Chan
9ba65213a9 Merge branch 'admin-GEN-57-all-together-now' into varun-admin-GEN-59-page
i hope

Co-Authored-By: Varun Murlidhar <vmurlidhar@unc.edu>
2024-04-20 15:04:06 -04:00
Andy Chan
7c69584d87 Merge branch 'admin-GEN-57-all-together-now' into varun-admin-GEN-59-page
i hope
Co-Authored-By: Varun <vmurlidhar@unc.edu>
2024-04-20 14:57:06 -04:00
Varun Murlidhar
7e36404757 Table styling 2024-04-20 14:38:17 -04:00
Ilakkiya Senthilkumar
e088027ee0 progress 2024-04-20 14:25:11 -04:00
Varun Murlidhar
cd276935ef Merge branch 'mel-admin-GEN-102-tag' into varun-admin-GEN-59-page 2024-04-20 12:00:44 -04:00
Varun Murlidhar
c53e12d776 Drawer changes 2024-04-20 11:58:18 -04:00
Andy Chan
0831387905 Implement UI of new row button
Functionality is extremely janky. Does not work well.
2024-04-19 18:20:08 -04:00
Ilakkiya Senthilkumar
a088dc1656 Ilakkiya_ContainsDropdown_Progress 2024-04-19 18:15:23 -04:00
apcodes
656edbfac7 added the filter box and edited the table action to add the filter UI 2024-04-19 16:49:30 -04:00
Andy Chan
ad25d5fe3f TableCell styling changes
i love `border`
2024-04-19 16:36:16 -04:00
Varun Murlidhar
b42080deae Make email field editable and remove input 2024-04-19 15:18:23 -04:00
Nicholas
8667ef6431 cleanup and checkpoint 2024-04-15 01:32:26 -04:00
Nicholas
b1b3313de0 New options now display on all rows 2024-04-15 00:54:50 -04:00
Nicholas
316a986f33 fixed problem where multiple tag dropdowns could be open at once. 2024-04-15 00:43:35 -04:00
Andy Chan
d689e22dd8 Add editing of table fields, significant refactoring
This makes RowOpenAction obsolete with PrimaryTableCell. Dropdowns to be
implemented at a later point. Lots of typing and further styling needed.
2024-04-13 13:02:12 -04:00
Nicholas
e3b31b0861 progress on handling clicks. 2024-04-13 10:36:40 -04:00
Varun Murlidhar
287f1c9502 Add row content to drawer 2024-04-12 20:21:30 -04:00
Nicholas
b86e5257f9 added search functionality for tags 2024-04-12 20:19:10 -04:00
Andy Chan
b188c783ce Move Table cell into its own component 2024-04-12 18:21:19 -04:00
Andy Chan
d94f4fddd6
Merge pull request #30 from cssgunc/andy-GEN-97-table-search 2024-04-10 23:40:12 -04:00
Andy Chan (12beesinatrenchcoat)
8aa4753199 Merge branch 'admin-GEN-97-get-tabled' into andy-GEN-97-table-search 2024-04-10 23:39:13 -04:00
Varun Murlidhar
11cda36cf9 Pass row content into drawer 2024-04-05 15:18:37 -04:00
Varun Murlidhar
5df525577a Merge branch 'mel-admin-GEN-102-tag' into varun-admin-GEN-59-page 2024-04-05 14:15:49 -04:00
Varun Murlidhar
044fe3e256 Merge branch 'frontend-team-GEN-68-admin-page' into varun-admin-GEN-59-page 2024-04-05 14:07:28 -04:00
Andy Chan (12beesinatrenchcoat)
ca9234afa1 Clean up search code 2024-04-05 12:23:57 -04:00
Varun Murlidhar
5c8a612df1 Add margin for icons 2024-04-05 11:27:23 -04:00
Varun Murlidhar
71833c629c Add togglable favorite icon to drawer 2024-04-05 11:21:52 -04:00
Andy Chan (12beesinatrenchcoat)
2f44ff5d2d Initial implementation of search
This is a big one...
2024-04-05 10:44:12 -04:00
Meliora Ho
167fa009ae Edited the dropdown action 2024-04-04 20:28:41 +00:00
Advik Arora
97fd4703ba added graying out of rows that are hidden, and allowed for hide to be a toggle 2024-04-03 15:40:10 -04:00
Advik Arora
8436709e37 added filtering for hide feature, also added visible attribute to users 2024-04-03 15:27:48 -04:00
Meliora Ho
a050d58e91 enabled add, delete, and deselect tags 2024-04-03 02:28:04 +00:00
Advik Arora
8b81f6cb4f added delete and RowOption component 2024-04-02 18:42:02 -04:00
Meliora Ho
95bf764e19 Merge remote-tracking branch 'origin/admin-GEN-97-get-tabled' into mel-admin-GEN-102-tag 2024-04-02 21:19:39 +00:00
Meliora Ho
f97783b9b5 Added dropdownAction 2024-04-02 21:17:13 +00:00
Meliora Ho
908cc07683 Tried 2024-04-01 00:45:18 +00:00
Andy Chan (12beesinatrenchcoat)
d82913a45d Add non-functional search bar
Does not do anything at the moment.
2024-03-31 18:47:12 -04:00
Andy Chan (12beesinatrenchcoat)
af8055002c Only show Drawer shadow when open 2024-03-31 00:35:15 -04:00
Andy Chan (12beesinatrenchcoat)
a59f9d784a Initial implementation of options menu 2024-03-31 00:32:25 -04:00
Varun Murlidhar
798a2fefa8 Add full-screen toggle to drawer 2024-03-30 12:12:49 -04:00
anikaahmed114
034906cfc1 update UI 2024-03-30 04:17:03 -04:00
anikaahmed114
85d99a0a96 added multiple input functionality 2024-03-30 04:07:40 -04:00
Andy Chan (12beesinatrenchcoat)
8258ba2666 Rename RowAction -> RowOpenAction 2024-03-29 14:11:01 -04:00
Meliora Ho
02c2e89325 made more changes 2024-03-29 17:10:04 +00:00
Meliora Ho
6f18419a74 Added drawer 2024-03-27 15:47:44 +00:00
Meliora Ho
80760c1f14 Reorder code 2024-03-27 14:13:57 +00:00
Meliora Ho
76077f271f
Merge pull request #26 from cssgunc/andy-admin-GEN-56-table-with-tanstack
Andy admin gen 56 table with tanstack
2024-03-27 10:08:15 -04:00
Meliora Ho
665b87169c
Merge branch 'admin-team-GEN-68-admin-app' into andy-admin-GEN-56-table-with-tanstack 2024-03-27 10:07:58 -04:00
Meliora Ho
01e73aa702 combined 2024-03-27 14:02:39 +00:00
Meliora Ho
e1d5318777
Merge pull request #25 from cssgunc/varun-admin-GEN-59-page
Varun admin gen 59 page
2024-03-26 19:35:20 -04:00
Meliora Ho
d9c97bae19
Merge pull request #24 from cssgunc/erica-admin-GEN-55-layout
Erica admin gen 55 layout
2024-03-26 19:35:00 -04:00
Andy Chan (12beesinatrenchcoat)
eb8c7e8f2c Rewrite table with TanStack Table
Not sure if this is the direction we want to go in, but...
2024-03-26 15:55:29 -04:00
Erica Birdsong
b5494a53d6 Added page interface 2024-03-26 13:25:20 -04:00
Varun Murlidhar
3331c1943d Add save functionality for page edits 2024-03-23 19:45:45 -04:00
Andy Chan (12beesinatrenchcoat)
b7e7046a88 oops i forgot the page 2024-03-23 15:16:44 -04:00
Andy Chan (12beesinatrenchcoat)
14ae6efa46 rendering table from data
i am going insane i love lists i love lists i love react i love lists i am oisdjghiuergihbenrjhloguehwirub
2024-03-23 15:16:25 -04:00
anikaahmed114
9a9fd33f19 added drawer component 2024-03-22 17:13:14 -04:00
Advik Arora
d8a4387d7f table component created 2024-03-22 16:48:31 -04:00
131 changed files with 15074 additions and 5310 deletions

View File

@ -37,6 +37,16 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
&& unlink /usr/bin/python3 \
&& ln -s /usr/bin/python3.11 /usr/bin/python3
# Install Node.js 18 from https://github.com/nodesource
ENV NODE_MAJOR 18
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install nodejs -y \
&& npm install -g npm@latest \
&& rm -rf /var/lib/apt/lists/*
# Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user
ARG USERNAME=vscode
ARG USER_UID=1000
@ -66,7 +76,7 @@ RUN curl https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.
# Set Locale for Functional Autocompletion in zsh
RUN sudo locale-gen en_US.UTF-8
# Install Database Dependencies
# Install Backend Dependencies
COPY backend/requirements.txt /workspace/backend/requirements.txt
WORKDIR /workspace/backend
RUN python3 -m pip install -r requirements.txt

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/backend/.env
__pycache__
.DS_Store
node_modules

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
cd compass
npm run lint
npm run prettier
git add .

View File

@ -10,6 +10,14 @@
## 📁 File Setup
```
\backend
\api // Define API routes
\entities // Define entities in database
\models // How objects are represented in Python
\script // Scripts for init and demo
\services // Main business logic
\test // Testing suite
\compass
\components // Components organized in folders related to specific pages
\pages // Store all pages here
@ -21,24 +29,24 @@
## 🚀 To Start
Follow these steps to set up your local environment:
Follow these steps to set up your local environment (Dev Container):
```
\\ Clone this repository
git clone https://github.com/cssgunc/compass.git
\\ Go into main folder
\\ Create .env file for frontend
cd compass
\\ Install dependencies
npm install
\\ Run local environment
npm run dev
touch .env
\\ Create .env file for backend
cd ../backend
touch .env
```
Also add following variables inside of a .env file inside of the backend directory
**Backend .env** Contents:
```
\\ .env file contents
POSTGRES_DATABASE=compass
POSTGRES_USER=postgres
POSTGRES_PASSWORD=admin
@ -47,19 +55,50 @@ POSTGRES_PORT=5432
HOST=localhost
```
## Backend Starter
**Frontend (compass) .env** Contents:
- Please open the VS Code Command Palette
```
NEXT_PUBLIC_SUPABASE_URL=[ASK_TECH_LEAD]
NEXT_PUBLIC_SUPABASE_ANON_KEY=[ASK_TECH_LEAD]
NEXT_PUBLIC_API_HOST=http://localhost:8000
NEXT_PUBLIC_HOST=http://localhost:3000
```
## Dev Container Setup
- Please open the VS Code Command Palette (Mac - Cmd+Shift+P and Windows - Ctrl+Shift+P)
- Run the command **Dev Containers: Rebuild and Reopen in Container**
- This should open the dev container with the same file directory mounted so any changes in the dev container will be seen in the local repo
- The dev container is sucessfully opened once you can see file directory getting populated
### In Dev Container
### In Dev Container Setup
Open a new terminal and run this script in sequence to setup the dependencies and database
Run this to reset the database and populate it with the approprate tables that reflect the entities folder
```
python3 -m backend.script.reset_demo
./start.sh
```
## Starting up website and backend
Open a terminal and run these commands:
```
cd backend
fastapi dev main.py
```
Open another terminal and run these commands:
```
cd compass
npm run dev
```
1. Go to [localhost:3000/auth/login](localhost:3000/auth/login)
2. Login with username: root@compass.com, password: compass123
3. Explore website
### Possible Dev Container Errors
- Sometimes the ports allocated to our services will be allocated (5432 for Postgres and 5050 for PgAdmin4)

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

1
backend/api/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Expose API routes via FastAPI routers from this package."""

19
backend/api/health.py Normal file
View File

@ -0,0 +1,19 @@
"""Confirm system health via monitorable API end points.
Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running.
"""
from fastapi import APIRouter, Depends
from ..services.health import HealthService
openapi_tags = {
"name": "System Health",
"description": "Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running.",
}
api = APIRouter(prefix="/api/health")
@api.get("", tags=["System Health"])
def health_check(health_svc: HealthService = Depends()) -> str:
return health_svc.check()

26
backend/api/resource.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from ..services import ResourceService, UserService
from ..models.resource_model import Resource
from typing import List
api = APIRouter(prefix="/api/resource")
openapi_tags = {
"name": "Resource",
"description": "Resource search and related operations.",
}
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions
@api.get("", response_model=List[Resource], tags=["Resource"])
def get_all(
user_id: str,
resource_svc: ResourceService = Depends(),
user_svc: UserService = Depends(),
):
subject = user_svc.get_user_by_uuid(user_id)
return resource_svc.get_resource_by_user(subject)

26
backend/api/service.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from ..services import ServiceService, UserService
from ..models.service_model import Service
from typing import List
api = APIRouter(prefix="/api/service")
openapi_tags = {
"name": "Service",
"description": "Service search and related operations.",
}
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions
@api.get("", response_model=List[Service], tags=["Service"])
def get_all(
user_id: str,
service_svc: ServiceService = Depends(),
user_svc: UserService = Depends(),
):
subject = user_svc.get_user_by_uuid(user_id)
return service_svc.get_service_by_user(subject)

30
backend/api/user.py Normal file
View File

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from ..services import UserService
from ..models.user_model import User, UserTypeEnum
from typing import List
api = APIRouter(prefix="/api/user")
openapi_tags = {
"name": "Users",
"description": "User profile search and related operations.",
}
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions
@api.get("/all", response_model=List[User], tags=["Users"])
def get_all(user_id: str, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(user_id)
if subject.role != UserTypeEnum.ADMIN:
raise Exception(f"Insufficient permissions for user {subject.uuid}")
return user_svc.all()
@api.get("/{user_id}", response_model=User, tags=["Users"])
def get_by_uuid(user_id: str, user_svc: UserService = Depends()):
return user_svc.get_user_by_uuid(user_id)

View File

@ -21,7 +21,6 @@ engine = sqlalchemy.create_engine(_engine_str(), echo=True)
def db_session():
"""Generator function offering dependency injection of SQLAlchemy Sessions."""
print("ran")
session = Session(engine)
try:
yield session

View File

@ -15,6 +15,7 @@ from datetime import datetime
# Import self for to model
from typing import Self
from backend.entities.program_enum import Program_Enum
from ..models.resource_model import Resource
class ResourceEntity(EntityBase):
@ -25,7 +26,7 @@ class ResourceEntity(EntityBase):
# set fields
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False)
link: Mapped[str] = mapped_column(String, nullable=False)
program: Mapped[Program_Enum] = mapped_column(Enum(Program_Enum), nullable=False)
@ -34,34 +35,33 @@ class ResourceEntity(EntityBase):
back_populates="resource", cascade="all,delete"
)
#
# @classmethod
# def from_model(cls, model: user_model) -> Self:
# """
# Create a UserEntity from a User model.
@classmethod
def from_model(cls, model: Resource) -> Self:
"""
Create a UserEntity from a User model.
# Args:
# model (User): The model to create the entity from.
Args:
model (User): The model to create the entity from.
# Returns:
# Self: The entity (not yet persisted).
# """
Returns:
Self: The entity (not yet persisted).
"""
# return cls (
# id = model.id,
# created_at = model.created_at,
# name = model.name,
# summary = model.summary,
# link = model.link,
# program = model.program,
# )
return cls(
id=model.id,
created_at=model.created_at,
name=model.name,
summary=model.summary,
link=model.link,
program=model.program,
)
# def to_model(self) -> user_model:
# return user_model (
# id = self.id,
# created_at = self.created_at,
# name = self.name,
# summary = self.summary,
# link = self.link,
# program = self.program,
# )
def to_model(self) -> Resource:
return Resource(
id=self.id,
created_at=self.created_at,
name=self.name,
summary=self.summary,
link=self.link,
program=self.program,
)

View File

@ -0,0 +1,12 @@
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .entity_base import EntityBase
class SampleEntity(EntityBase):
__tablename__ = "persons"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
age: Mapped[int] = mapped_column(Integer)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)

View File

@ -13,9 +13,12 @@ from .entity_base import EntityBase
from datetime import datetime
# Import enums for Program
from .program_enum import Program_Enum
import enum
from sqlalchemy import Enum
from backend.models.service_model import Service
from typing import Self
from backend.models.enum_for_models import ProgramTypeEnum
class ServiceEntity(EntityBase):
@ -26,11 +29,19 @@ class ServiceEntity(EntityBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False)
requirements: Mapped[list[str]] = mapped_column(ARRAY(String))
program: Mapped[Program_Enum] = mapped_column(Enum(Program_Enum), nullable=False)
program: Mapped[ProgramTypeEnum] = mapped_column(Enum(ProgramTypeEnum), nullable=False)
# relationships
serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(
back_populates="service", cascade="all,delete"
)
def to_model(self) -> Service:
return Service(id=self.id, name=self.name, status=self.status, summary=self.summary, requirements=self.requirements, program=self.program)
@classmethod
def from_model(cls, model:Service) -> Self:
return cls(id=model.id, name=model.name, status=model.status, summary=model.summary, requirements=model.requirements, program=model.program)

View File

@ -13,8 +13,7 @@ from .entity_base import EntityBase
from datetime import datetime
# Import enums for Role and Program
from .program_enum import Program_Enum
from .user_enum import Role_Enum
from backend.models.enum_for_models import UserTypeEnum, ProgramTypeEnum
# Import models for User methods
from ..models.user_model import User
@ -34,13 +33,14 @@ class UserEntity(EntityBase):
username: Mapped[str] = mapped_column(
String(32), nullable=False, default="", unique=True
)
role: Mapped[Role_Enum] = mapped_column(Enum(Role_Enum), nullable=False)
role: Mapped[UserTypeEnum] = mapped_column(Enum(UserTypeEnum), nullable=False)
email: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
program: Mapped[list[Program_Enum]] = mapped_column(
ARRAY(Enum(Program_Enum)), nullable=False
program: Mapped[list[ProgramTypeEnum]] = mapped_column(
ARRAY(Enum(ProgramTypeEnum)), nullable=False
)
experience: Mapped[int] = mapped_column(Integer, nullable=False)
group: Mapped[str] = mapped_column(String(50))
uuid: Mapped[str] = mapped_column(String, nullable=True)
@classmethod
def from_model(cls, model: User) -> Self:
@ -62,6 +62,7 @@ class UserEntity(EntityBase):
program=model.program,
experience=model.experience,
group=model.group,
uuid=model.uuid,
)
def to_model(self) -> User:
@ -83,4 +84,5 @@ class UserEntity(EntityBase):
program=self.program,
role=self.role,
created_at=self.created_at,
uuid=self.uuid,
)

View File

@ -0,0 +1,34 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.gzip import GZipMiddleware
from .api import user, health, service, resource
description = """
Welcome to the **COMPASS** RESTful Application Programming Interface.
"""
app = FastAPI(
title="Compass API",
version="0.0.1",
description=description,
openapi_tags=[
user.openapi_tags,
health.openapi_tags,
service.openapi_tags,
resource.openapi_tags,
],
)
app.add_middleware(GZipMiddleware)
feature_apis = [user, health, service, resource]
for feature_api in feature_apis:
app.include_router(feature_api.api)
# Add application-wide exception handling middleware for commonly encountered API Exceptions
@app.exception_handler(Exception)
def permission_exception_handler(request: Request, e: Exception):
return JSONResponse(status_code=403, content={"message": str(e)})

View File

@ -4,7 +4,6 @@ from typing import List
from datetime import datetime
from typing import Optional
from .enum_for_models import ProgramTypeEnum
from .resource_model import Resource
class Resource(BaseModel):
@ -12,5 +11,5 @@ class Resource(BaseModel):
name: str = Field(..., max_length=150, description="The name of the resource")
summary: str = Field(..., max_length=300, description="The summary of the resource")
link: str = Field(..., max_length=150, description="link to the resource")
programtype: ProgramTypeEnum
program: ProgramTypeEnum
created_at: Optional[datetime]

View File

@ -15,3 +15,4 @@ class User(BaseModel):
program: List[ProgramTypeEnum]
role: UserTypeEnum
created_at: Optional[datetime]
uuid: str | None = None

View File

@ -1,4 +1,5 @@
fastapi[all] >=0.100.0, <0.101.0
fastapi[standard] >=0.100.0, <0.101.0
fastapi-cli
sqlalchemy >=2.0.4, <2.1.0
psycopg2 >=2.9.5, <2.10.0
alembic >=1.10.2, <1.11.0

View File

@ -1,10 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
import subprocess
from ..database import engine, _engine_str
from ..env import getenv
from .. import entities
from ..test.services import user_test_data, service_test_data, resource_test_data
database = getenv("POSTGRES_DATABASE")
engine = create_engine(_engine_str(), echo=True)
@ -16,3 +19,9 @@ subprocess.run(["python3", "-m", "backend.script.create_database"])
entities.EntityBase.metadata.drop_all(engine)
entities.EntityBase.metadata.create_all(engine)
with Session(engine) as session:
user_test_data.insert_test_data(session)
service_test_data.insert_test_data(session)
resource_test_data.insert_test_data(session)
session.commit()

View File

@ -0,0 +1,25 @@
"""
This file contains exceptions found in the service layer.
These custom exceptions can then be handled peoperly
at the API level.
"""
class ResourceNotFoundException(Exception):
"""ResourceNotFoundException is raised when a user attempts to access a resource that does not exist."""
class UserPermissionException(Exception):
"""UserPermissionException is raised when a user attempts to perform an action they are not authorized to perform."""
def __init__(self, action: str, resource: str):
super().__init__(f"Not authorized to perform `{action}` on `{resource}`")
class ServiceNotFoundException(Exception):
"""Exception for when the service being requested is not in the table."""
class ProgramNotAssignedException(Exception):
"""Exception for when the user does not have correct access for requested services."""

View File

@ -0,0 +1,27 @@
"""
Verify connectivity to the database from the service layer for health check purposes.
The production system will regularly check the health of running containers via accessing an API endpoint.
The API endpoint is backed by this service which executes a simple statement against our backing database.
In more complex deployments, where multiple backing services may be depended upon, the health check process
would necessarily also become more complex to reflect the health of all subsystems.
In this context health does not refer to correctness as much as running, connected, and responsive.
"""
from fastapi import Depends
from sqlalchemy import text
from ..database import Session, db_session
class HealthService:
_session: Session
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def check(self):
stmt = text("SELECT 'OK', NOW()")
result = self._session.execute(stmt)
row = result.all()[0]
return str(f"{row[0]} @ {row[1]}")

View File

@ -0,0 +1,37 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from ..models.user_model import User
from ..entities.user_entity import UserEntity
from exceptions import ResourceNotFoundException, UserPermissionException
from ..models.enum_for_models import UserTypeEnum
class PermissionsService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_role_permissions(self, user: User) -> str:
"""
Gets a str group based on the user
Returns:
str
"""
# Query the resource table with id
obj = (
self._session.query(UserEntity)
.filter(UserEntity.id == user.id)
.one_or_none()
)
# Check if result is null
if obj is None:
raise ResourceNotFoundException(
f"No user permissions found for user with id: {user.id}"
)
return obj.role

View File

@ -1,9 +1,165 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from sqlalchemy import select
from ..models.resource_model import Resource
from ..entities.resource_entity import ResourceEntity
from ..models.user_model import User, UserTypeEnum
from .exceptions import ResourceNotFoundException
class ResourceService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_resource_by_user(self, subject: User):
"""Resource method getting all of the resources that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ResourceEntity)
entities = self._session.scalars(query).all()
return [resource.to_model() for resource in entities]
else:
programs = subject.program
resources = []
for program in programs:
query = select(ResourceEntity).filter(ResourceEntity.program == program)
entities = self._session.scalars(query).all()
for entity in entities:
resources.append(entity)
return [resource.to_model() for resource in resources]
def create(self, user: User, resource: Resource) -> Resource:
"""
Creates a resource based on the input object and adds it to the table if the user has the right permissions.
Parameters:
user: a valid User model representing the currently logged in User
resource: Resource object to add to table
Returns:
Resource: Object added to table
"""
if resource.role != user.role or resource.group != user.group:
raise PermissionError(
"User does not have permission to add resources in this role or group."
)
resource_entity = ResourceEntity.from_model(resource)
self._session.add(resource_entity)
self._session.commit()
return resource_entity.to_model()
def get_by_id(self, user: User, id: int) -> Resource:
"""
Gets a resource based on the resource id that the user has access to
Parameters:
user: a valid User model representing the currently logged in User
id: int, the id of the resource
Returns:
Resource
Raises:
ResourceNotFoundException: If no resource is found with id
"""
resource = (
self._session.query(ResourceEntity)
.filter(
ResourceEntity.id == id,
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
)
.one_or_none()
)
if resource is None:
raise ResourceNotFoundException(f"No resource found with id: {id}")
return resource.to_model()
def update(self, user: User, resource: ResourceEntity) -> Resource:
"""
Update the resource if the user has access
Parameters:
user: a valid User model representing the currently logged in User
resource (ResourceEntity): Resource to update
Returns:
Resource: Updated resource object
Raises:
ResourceNotFoundException: If no resource is found with the corresponding ID
"""
if resource.role != user.role or resource.group != user.group:
raise PermissionError(
"User does not have permission to update this resource."
)
obj = self._session.get(ResourceEntity, resource.id) if resource.id else None
if obj is None:
raise ResourceNotFoundException(
f"No resource found with matching id: {resource.id}"
)
obj.update_from_model(resource) # Assuming an update method exists
self._session.commit()
return obj.to_model()
def delete(self, user: User, id: int) -> None:
"""
Delete resource based on id that the user has access to
Parameters:
user: a valid User model representing the currently logged in User
id: int, a unique resource id
Raises:
ResourceNotFoundException: If no resource is found with the corresponding id
"""
resource = (
self._session.query(ResourceEntity)
.filter(
ResourceEntity.id == id,
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
)
.one_or_none()
)
if resource is None:
raise ResourceNotFoundException(f"No resource found with matching id: {id}")
self._session.delete(resource)
self._session.commit()
def get_by_slug(self, user: User, search_string: str) -> list[Resource]:
"""
Get a list of resources given a search string that the user has access to
Parameters:
user: a valid User model representing the currently logged in User
search_string: a string to search resources by
Returns:
list[Resource]: list of resources relating to the string
Raises:
ResourceNotFoundException if no resource is found with the corresponding slug
"""
query = select(ResourceEntity).where(
ResourceEntity.title.ilike(f"%{search_string}%"),
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities]

View File

@ -1,9 +1,127 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from sqlalchemy import func, select, and_, func, or_, exists, or_
from backend.models.service_model import Service
from backend.models.user_model import User
from backend.entities.service_entity import ServiceEntity
from backend.models.enum_for_models import ProgramTypeEnum, UserTypeEnum
from backend.services.exceptions import (
ServiceNotFoundException,
ProgramNotAssignedException,
)
class ServiceService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_service_by_program(self, program: ProgramTypeEnum) -> list[Service]:
"""Service method getting services belonging to a particular program."""
query = select(ServiceEntity).filter(ServiceEntity.program == program)
entities = self._session.scalars(query)
return [entity.to_model() for entity in entities]
def get_service_by_id(self, id: int) -> Service:
"""Service method getting services by id."""
query = select(ServiceEntity).filter(ServiceEntity.id == id)
entity = self._session.scalars(query).one_or_none()
if entity is None:
raise ServiceNotFoundException(f"Service with id: {id} does not exist")
return entity.to_model()
def get_service_by_name(self, name: str) -> Service:
"""Service method getting services by id."""
query = select(ServiceEntity).filter(ServiceEntity.name == name)
entity = self._session.scalars(query).one_or_none()
if entity is None:
raise ServiceNotFoundException(f"Service with name: {name} does not exist")
return entity.to_model()
def get_service_by_user(self, subject: User):
"""Service method getting all of the services that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ServiceEntity)
entities = self._session.scalars(query).all()
return [service.to_model() for service in entities]
else:
programs = subject.program
services = []
for program in programs:
query = select(ServiceEntity).filter(ServiceEntity.program == program)
entities = self._session.scalars(query).all()
for entity in entities:
services.append(entity)
return [service.to_model() for service in services]
def get_all(self, subject: User) -> list[Service]:
"""Service method retrieving all of the services in the table."""
if subject.role == UserTypeEnum.VOLUNTEER:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN} or {UserTypeEnum.VOLUNTEER}, cannot get all"
)
query = select(ServiceEntity)
entities = self._session.scalars(query).all()
return [service.to_model() for service in entities]
def create(self, subject: User, service: Service) -> Service:
"""Creates/adds a service to the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot create service"
)
service_entity = ServiceEntity.from_model(service)
self._session.add(service_entity)
self._session.commit()
return service_entity.to_model()
def update(self, subject: User, service: Service) -> Service:
"""Updates a service if in the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot update service"
)
service_entity = self._session.get(ServiceEntity, service.id)
if service_entity is None:
raise ServiceNotFoundException(
"The service you are searching for does not exist."
)
service_entity.name = service.name
service_entity.status = service.status
service_entity.summary = service.summary
service_entity.requirements = service.requirements
service_entity.program = service.program
self._session.commit()
return service_entity.to_model()
def delete(self, subject: User, service: Service) -> None:
"""Deletes a service from the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}")
service_entity = self._session.get(ServiceEntity, service.id)
if service_entity is None:
raise ServiceNotFoundException(
"The service you are searching for does not exist."
)
self._session.delete(service_entity)
self._session.commit()

View File

@ -26,6 +26,21 @@ class UserService:
return user_entity.to_model()
def get_user_by_uuid(self, uuid: str) -> User:
"""
Gets a user by uuid from the database
Returns: A User Pydantic model
"""
query = select(UserEntity).where(UserEntity.uuid == uuid)
user_entity: UserEntity | None = self._session.scalar(query)
if user_entity is None:
raise Exception(f"No user found with matching uuid: {uuid}")
return user_entity.to_model()
def all(self) -> list[User]:
"""
Returns a list of all Users
@ -46,7 +61,8 @@ class UserService:
"""
try:
user = self.get_user_by_id(user.id)
if (user.id != None):
user = self.get_user_by_id(user.id)
except:
# if does not exist, create new object
user_entity = UserEntity.from_model(user)

View File

@ -4,7 +4,7 @@ import pytest
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.orm import Session
from sqlalchemy.exc import OperationalError
from .services import user_test_data, tag_test_data
from .services import user_test_data, tag_test_data, service_test_data
from ..database import _engine_str
from ..env import getenv
@ -56,5 +56,6 @@ def session(test_engine: Engine):
def setup_insert_data_fixture(session: Session):
user_test_data.insert_fake_data(session)
tag_test_data.insert_fake_data(session)
service_test_data.insert_fake_data(session)
session.commit()
yield

View File

@ -5,6 +5,7 @@ from unittest.mock import create_autospec
from sqlalchemy.orm import Session
from ...services import UserService
from ...services import TagService
from ...services import ServiceService
@ -18,3 +19,8 @@ def user_svc(session: Session):
def tag_svc(session: Session):
"""This fixture is used to test the TagService class"""
return TagService(session)
@pytest.fixture()
def service_svc(session: Session):
"""This fixture is used to test the ServiceService class"""
return ServiceService(session)

View File

@ -0,0 +1,315 @@
from sqlalchemy.orm import Session
from datetime import datetime
from ...entities import ResourceEntity
from ...models.enum_for_models import ProgramTypeEnum
from ...models.resource_model import Resource
resource1 = Resource(
id=1,
name="Resource 1",
summary="Helpful information for victims of domestic violence",
link="https://example.com/resource1",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 1, 10, 0, 0),
)
resource2 = Resource(
id=2,
name="Resource 2",
summary="Legal assistance resources",
link="https://example.com/resource2",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 2, 12, 30, 0),
)
resource3 = Resource(
id=3,
name="Resource 3",
summary="Financial aid resources",
link="https://example.com/resource3",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 3, 15, 45, 0),
)
resource4 = Resource(
id=4,
name="Resource 4",
summary="Counseling and support groups",
link="https://example.com/resource4",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 4, 9, 15, 0),
)
resource5 = Resource(
id=5,
name="Resource 5",
summary="Shelter and housing resources",
link="https://example.com/resource5",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resources = [resource1, resource2, resource3, resource4, resource5]
resource_1 = Resource(
id=1,
name="National Domestic Violence Hotline",
summary="24/7 confidential support for victims of domestic violence",
link="https://www.thehotline.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 1, 10, 0, 0),
)
resource_2 = Resource(
id=2,
name="Legal Aid Society",
summary="Free legal assistance for low-income individuals",
link="https://www.legalaidnyc.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 2, 12, 30, 0),
)
resource_3 = Resource(
id=3,
name="Financial Empowerment Center",
summary="Free financial counseling and education services",
link="https://www1.nyc.gov/site/dca/consumers/get-free-financial-counseling.page",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 3, 15, 45, 0),
)
resource_4 = Resource(
id=4,
name="National Coalition Against Domestic Violence",
summary="Resources and support for victims of domestic violence",
link="https://ncadv.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 4, 9, 15, 0),
)
resource_5 = Resource(
id=5,
name="Safe Horizon",
summary="Shelter and support services for victims of violence",
link="https://www.safehorizon.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resource_6 = Resource(
id=6,
name="National Sexual Assault Hotline",
summary="24/7 confidential support for survivors of sexual assault",
link="https://www.rainn.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 6, 14, 0, 0),
)
resource_7 = Resource(
id=7,
name="Victim Compensation Fund",
summary="Financial assistance for victims of crime",
link="https://ovc.ojp.gov/program/victim-compensation",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 7, 16, 45, 0),
)
resource_8 = Resource(
id=8,
name="Battered Women's Justice Project",
summary="Legal and technical assistance for victims of domestic violence",
link="https://www.bwjp.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 8, 10, 30, 0),
)
resource_9 = Resource(
id=9,
name="National Network to End Domestic Violence",
summary="Advocacy and resources for ending domestic violence",
link="https://nnedv.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 9, 13, 0, 0),
)
resource_10 = Resource(
id=10,
name="Economic Justice Project",
summary="Promoting economic security for survivors of domestic violence",
link="https://www.njep.org",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 10, 15, 15, 0),
)
resource_11 = Resource(
id=11,
name="Domestic Violence Legal Hotline",
summary="Free legal advice for victims of domestic violence",
link="https://www.womenslaw.org/find-help/national/hotlines",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 11, 9, 0, 0),
)
resource_12 = Resource(
id=12,
name="National Resource Center on Domestic Violence",
summary="Comprehensive information and resources on domestic violence",
link="https://nrcdv.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 12, 11, 30, 0),
)
resource_13 = Resource(
id=13,
name="Financial Assistance for Victims of Crime",
summary="Funding for expenses related to victimization",
link="https://ovc.ojp.gov/program/victim-assistance-funding",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 13, 14, 45, 0),
)
resource_14 = Resource(
id=14,
name="National Clearinghouse for the Defense of Battered Women",
summary="Legal resources and support for battered women",
link="https://www.ncdbw.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 14, 10, 0, 0),
)
resource_15 = Resource(
id=15,
name="Victim Connect Resource Center",
summary="Referral helpline for crime victims",
link="https://victimconnect.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 15, 13, 15, 0),
)
resource_16 = Resource(
id=16,
name="Economic Empowerment Program",
summary="Financial literacy and job readiness training for survivors",
link="https://www.purplepurse.com",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 16, 16, 30, 0),
)
resource_17 = Resource(
id=17,
name="National Domestic Violence Law Project",
summary="Legal information and resources for domestic violence survivors",
link="https://www.womenslaw.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 17, 9, 45, 0),
)
resource_18 = Resource(
id=18,
name="Victim Rights Law Center",
summary="Free legal services for victims of sexual assault",
link="https://victimrights.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 18, 12, 0, 0),
)
resource_19 = Resource(
id=19,
name="Financial Justice Project",
summary="Advocating for economic justice for survivors of violence",
link="https://www.financialjusticeproject.org",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 19, 15, 30, 0),
)
resource_20 = Resource(
id=20,
name="National Center on Domestic and Sexual Violence",
summary="Training and resources to end domestic and sexual violence",
link="http://www.ncdsv.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 20, 10, 15, 0),
)
resources1 = [
resource_1,
resource_2,
resource_3,
resource_4,
resource_5,
resource_6,
resource_7,
resource_8,
resource_9,
resource_10,
resource_11,
resource_12,
resource_13,
resource_14,
resource_15,
resource_16,
resource_17,
resource_18,
resource_19,
resource_20,
]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
next_id: int,
) -> None:
"""Reset the ID sequence of an entity table.
Args:
session (Session) - A SQLAlchemy Session
entity (DeclarativeBase) - The SQLAlchemy Entity table to target
entity_id_column (MappedColumn) - The ID column (should be an int column)
next_id (int) - Where the next inserted, autogenerated ID should begin
Returns:
None"""
table = entity.__table__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_test_data(session: Session):
"""Inserts fake resource data into the test session."""
global resources1
# Create entities for test resource data
entities = []
for resource in resources1:
entity = ResourceEntity.from_model(resource)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources1) + 1)
# Commit all changes
session.commit()
def insert_fake_data(session: Session):
"""Inserts fake resource data into the test session."""
global resources
# Create entities for test resource data
entities = []
for resource in resources:
entity = ResourceEntity.from_model(resource)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1)
# Commit all changes
session.commit()

View File

@ -0,0 +1,78 @@
from backend.models.user_model import User
from backend.entities.service_entity import ServiceEntity
from ...models.enum_for_models import ProgramTypeEnum
from backend.services.service import ServiceService
from backend.services.exceptions import ServiceNotFoundException
from . import service_test_data
from . import user_test_data
from .fixtures import service_svc, user_svc
from backend.models.service_model import Service
import pytest
def test_list(service_svc: ServiceService):
service = service_svc.get_all(user_test_data.admin)
assert len(service) == len(service_test_data.services)
assert isinstance(service[0], Service)
def test_get_by_name(service_svc: ServiceService):
service = service_svc.get_service_by_name("service 1")
assert service.name == service_test_data.service1.name
assert isinstance(service, Service)
def test_get_by_name_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service = service_svc.get_service_by_name("service 12")
pytest.fail()
def test_get_service_by_user_admin(service_svc: ServiceService):
service = service_svc.get_service_by_user(user_test_data.admin)
assert len(service) == len(service_test_data.services)
def test_get_service_by_user_volun(service_svc: ServiceService):
service = service_svc.get_service_by_user(user_test_data.volunteer)
assert len(service) == 4
def test_get_by_program(service_svc: ServiceService):
services = service_svc.get_service_by_program(ProgramTypeEnum.COMMUNITY)
for service in services:
assert service.program == ProgramTypeEnum.COMMUNITY
assert isinstance(service, Service)
def test_create(service_svc: ServiceService):
service = service_svc.create(user_test_data.admin, service_test_data.service7)
assert service.name == service_test_data.service7.name
assert isinstance(service, Service)
def test_update(service_svc: ServiceService):
service = service_svc.update(user_test_data.admin, service_test_data.service_6_edit)
assert service.status == service_test_data.service_6_edit.status
assert service.requirements == service_test_data.service_6_edit.requirements
assert isinstance(service, Service)
def test_update_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service = service_svc.update(
user_test_data.admin, service_test_data.new_service
)
pytest.fail()
def test_delete(service_svc: ServiceService):
service_svc.delete(user_test_data.admin, service_test_data.service_6)
services = service_svc.get_all(user_test_data.admin)
assert len(services) == len(service_test_data.services) - 1
"""def test_delete_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service_svc.delete(user_test_data.admin, service_test_data.service_10)
pytest.fail()"""

View File

@ -0,0 +1,353 @@
import pytest
from sqlalchemy.orm import Session
from ...entities import ServiceEntity
from ...models.enum_for_models import ProgramTypeEnum
from ...models.service_model import Service
service1 = Service(
id=1,
name="service 1",
status="open",
summary="presentation educating community on domestic violence",
requirements=[""],
program=ProgramTypeEnum.COMMUNITY,
)
service2 = Service(
id=2,
name="service 2",
status="closed",
summary="service finding safe places to stay",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
service3 = Service(
id=3,
name="service 3",
status="open",
summary="",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
service4 = Service(
id=4,
name="service 4",
status="waitlist",
summary="community event",
requirements=[""],
program=ProgramTypeEnum.COMMUNITY,
)
service5 = Service(
id=5,
name="service 5",
status="open",
summary="talk circle for victims of domestic violence",
requirements=["18+"],
program=ProgramTypeEnum.COMMUNITY,
)
service6 = Service(
id=6,
name="service 6",
status="waitlist",
summary="program offering economic assistance",
requirements=[""],
program=ProgramTypeEnum.ECONOMIC,
)
service_6_edit = Service(
id=6,
name="service 6",
status="open",
summary="program offering economic assistance",
requirements=["18+"],
program=ProgramTypeEnum.ECONOMIC,
)
service7 = Service(
id=7,
name="service 7",
status="waitlist",
summary="insert generic description",
requirements=[""],
program=ProgramTypeEnum.ECONOMIC,
)
new_service = Service(
id=8,
name="new service",
status="open",
summary="insert other generic description",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
services = [service1, service2, service3, service4, service5, service6]
service_1 = Service(
id=1,
name="Crisis Hotline",
status="open",
summary="24/7 support for individuals in crisis",
requirements=["Anonymous", "Confidential"],
program=ProgramTypeEnum.DOMESTIC,
)
service_2 = Service(
id=2,
name="Shelter Placement",
status="open",
summary="Emergency shelter for victims of domestic violence",
requirements=["Referral required", "Safety assessment"],
program=ProgramTypeEnum.DOMESTIC,
)
service_3 = Service(
id=3,
name="Legal Advocacy",
status="waitlist",
summary="Legal support and representation for survivors",
requirements=["Intake required", "Income eligibility"],
program=ProgramTypeEnum.COMMUNITY,
)
service_4 = Service(
id=4,
name="Counseling Services",
status="open",
summary="Individual and group therapy for survivors",
requirements=["Initial assessment", "Insurance accepted"],
program=ProgramTypeEnum.DOMESTIC,
)
service_5 = Service(
id=5,
name="Financial Assistance",
status="open",
summary="Emergency funds for survivors in need",
requirements=["Application required", "Proof of income"],
program=ProgramTypeEnum.ECONOMIC,
)
service_6 = Service(
id=6,
name="Housing Assistance",
status="waitlist",
summary="Support for finding safe and affordable housing",
requirements=["Referral required", "Background check"],
program=ProgramTypeEnum.ECONOMIC,
)
service_7 = Service(
id=7,
name="Job Training",
status="open",
summary="Employment skills training for survivors",
requirements=["Enrollment required", "18+"],
program=ProgramTypeEnum.ECONOMIC,
)
service_8 = Service(
id=8,
name="Support Groups",
status="open",
summary="Peer support groups for survivors",
requirements=["Registration required", "Confidential"],
program=ProgramTypeEnum.COMMUNITY,
)
service_9 = Service(
id=9,
name="Children's Services",
status="open",
summary="Specialized services for children exposed to domestic violence",
requirements=["Parental consent", "Age-appropriate"],
program=ProgramTypeEnum.DOMESTIC,
)
service_10 = Service(
id=10,
name="Safety Planning",
status="open",
summary="Personalized safety planning for survivors",
requirements=["Confidential", "Collaborative"],
program=ProgramTypeEnum.DOMESTIC,
)
service_11 = Service(
id=11,
name="Community Education",
status="open",
summary="Workshops and training on domestic violence prevention",
requirements=["Open to the public", "Registration preferred"],
program=ProgramTypeEnum.COMMUNITY,
)
service_12 = Service(
id=12,
name="Healthcare Services",
status="open",
summary="Medical care and support for survivors",
requirements=["Referral required", "Insurance accepted"],
program=ProgramTypeEnum.DOMESTIC,
)
service_13 = Service(
id=13,
name="Transportation Assistance",
status="waitlist",
summary="Help with transportation for survivors",
requirements=["Eligibility assessment", "Limited availability"],
program=ProgramTypeEnum.ECONOMIC,
)
service_14 = Service(
id=14,
name="Court Accompaniment",
status="open",
summary="Support and advocacy during court proceedings",
requirements=["Legal case", "Scheduling required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_15 = Service(
id=15,
name="Relocation Assistance",
status="waitlist",
summary="Support for relocating to a safe environment",
requirements=["Referral required", "Safety assessment"],
program=ProgramTypeEnum.ECONOMIC,
)
service_16 = Service(
id=16,
name="Parenting Classes",
status="open",
summary="Education and support for parents",
requirements=["Open to parents", "Pre-registration required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_17 = Service(
id=17,
name="Life Skills Training",
status="open",
summary="Workshops on various life skills for survivors",
requirements=["Enrollment required", "Commitment to attend"],
program=ProgramTypeEnum.ECONOMIC,
)
service_18 = Service(
id=18,
name="Advocacy Services",
status="open",
summary="Individual advocacy and support for survivors",
requirements=["Intake required", "Confidential"],
program=ProgramTypeEnum.DOMESTIC,
)
service_19 = Service(
id=19,
name="Volunteer Opportunities",
status="open",
summary="Various volunteer roles supporting the organization",
requirements=["Background check", "Training required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_20 = Service(
id=20,
name="Referral Services",
status="open",
summary="Referrals to community resources and partner agencies",
requirements=["Intake required", "Based on individual needs"],
program=ProgramTypeEnum.DOMESTIC,
)
services1 = [
service_1,
service_2,
service_3,
service_4,
service_5,
service_6,
service_7,
service_8,
service_9,
service_10,
service_11,
service_12,
service_13,
service_14,
service_15,
service_16,
service_17,
service_18,
service_19,
service_20,
]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
next_id: int,
) -> None:
"""Reset the ID sequence of an entity table.
Args:
session (Session) - A SQLAlchemy Session
entity (DeclarativeBase) - The SQLAlchemy Entity table to target
entity_id_column (MappedColumn) - The ID column (should be an int column)
next_id (int) - Where the next inserted, autogenerated ID should begin
Returns:
None"""
table = entity.__table__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_test_data(session: Session):
"""Inserts fake service data into the test session."""
global services1
# Create entities for test organization data
entities = []
for service in services1:
entity = ServiceEntity.from_model(service)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ServiceEntity, ServiceEntity.id, len(services1) + 1)
# Commit all changes
session.commit()
def insert_fake_data(session: Session):
"""Inserts fake service data into the test session."""
global services
# Create entities for test organization data
entities = []
for service in services:
entity = ServiceEntity.from_model(service)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ServiceEntity, ServiceEntity.id, len(services) + 1)
# Commit all changes
session.commit()

View File

@ -36,7 +36,8 @@ def test_get_all(user_svc: UserService):
def test_get_user_by_id(user_svc: UserService):
"""Test getting a user by an id"""
user = user_svc.get_user_by_id(volunteer.id)
if volunteer.id != None:
user = user_svc.get_user_by_id(volunteer.id)
assert user is not None
assert user.id is not None

View File

@ -13,17 +13,19 @@ roles = UserTypeEnum
volunteer = User(
id=1,
uuid="test1",
username="volunteer",
email="volunteer@compass.com",
experience=1,
group="volunteers",
program=[programs.COMMUNITY],
program=[programs.COMMUNITY, programs.ECONOMIC],
created_at=datetime.now(),
role=UserTypeEnum.VOLUNTEER,
)
employee = User(
id=2,
uuid="test2",
username="employee",
email="employee@compass.com",
experience=5,
@ -35,6 +37,7 @@ employee = User(
admin = User(
id=3,
uuid="test3",
username="admin",
email="admin@compass.com",
experience=10,
@ -51,6 +54,7 @@ admin = User(
newUser = User(
id=4,
username="new",
uuid="test4",
email="new@compass.com",
experience=1,
group="volunteer",
@ -67,11 +71,57 @@ toDelete = User(
group="none",
program=[programs.COMMUNITY],
created_at=datetime.now(),
role=roles.VOLUNTEER
role=roles.VOLUNTEER,
)
users = [volunteer, employee, admin, toDelete]
admin1 = User(
username="Prajwal Moharana",
uuid="acc6e112-d296-4739-a80c-b89b2933e50b",
email="root@compass.com",
experience=10,
group="admin",
program=[programs.ECONOMIC, programs.DOMESTIC, programs.COMMUNITY],
created_at=datetime.now(),
role=roles.ADMIN,
)
employee1 = User(
username="Mel Ho",
uuid="c5fcff86-3deb-4d09-9f60-9b529e40161a",
email="employee@compass.com",
experience=5,
group="employee",
program=[programs.ECONOMIC, programs.DOMESTIC, programs.COMMUNITY],
created_at=datetime.now(),
role=roles.EMPLOYEE,
)
volunteer1 = User(
username="Pranav Wagh",
uuid="1d2e114f-b286-4464-8528-d177dc226b09",
email="volunteer1@compass.com",
experience=2,
group="volunteer",
program=[programs.DOMESTIC],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
volunteer2 = User(
username="Yashu Singhai",
uuid="13888204-1bae-4be4-8192-1ca46be4fc7d",
email="volunteer2@compass.com",
experience=1,
group="volunteer",
program=[programs.COMMUNITY, programs.ECONOMIC],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
users1 = [admin1, employee1, volunteer1, volunteer2]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
@ -118,12 +168,29 @@ def insert_fake_data(session: Session):
session.commit()
def insert_test_data(session: Session):
"""Inserts fake organization data into the test session."""
global users1
# Create entities for test organization data
for user in users1:
entity = UserEntity.from_model(user)
session.add(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, UserEntity, UserEntity.id, len(users1) + 1)
# Commit all changes
session.commit()
@pytest.fixture(autouse=True)
def fake_data_fixture(session: Session):
"""Insert fake data the session automatically when test is run.
Note:
This function runs automatically due to the fixture property `autouse=True`.
"""
# insert_fake_data(session)
insert_fake_data(session)
session.commit()
yield

View File

@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
}
{
"extends": "next/core-web-vitals"
}

3
compass/.gitignore vendored
View File

@ -33,3 +33,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# environment variables
.env

6
compass/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": false
}

View File

@ -1,36 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,100 @@
"use client";
import Sidebar from "@/components/Sidebar/Sidebar";
import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed admin page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
if (user.role !== Role.ADMIN) {
console.log(
`Accessed admin page but incorrect permissions: ${user.username} ${user.role}`
);
router.push("/home");
return;
}
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { PageLayout } from "@/components/PageLayout";
import { Table } from "@/components/Table/Index";
import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client";
import { UsersIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const userListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user/all?uuid=${data.user.id}`
);
const users: User[] = await userListData.json();
setUsers(users);
}
getUser();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}>
<Table users={users} />
</PageLayout>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/health`;
const result = await fetch(apiEndpoint);
return NextResponse.json(await result.json(), { status: result.status });
}

View File

@ -0,0 +1,24 @@
import Resource from "@/utils/models/Resource";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/resource`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const resourceData: Resource[] = await data.json();
// TODO: Remove make every resource visible
const resources = resourceData.map((resource: Resource) => {
resource.visible = true;
return resource;
});
return NextResponse.json(resources, { status: data.status });
}

5
compass/app/api/route.ts Normal file
View File

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello World!" }, { status: 200 });
}

View File

@ -0,0 +1,24 @@
import Service from "@/utils/models/Service";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/service`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const serviceData: Service[] = await data.json();
// TODO: Remove make every service visible
const services = serviceData.map((service: Service) => {
service.visible = true;
return service;
});
return NextResponse.json(services, { status: data.status });
}

View File

@ -0,0 +1,24 @@
import User from "@/utils/models/User";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user/all`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const userData: User[] = await data.json();
// TODO: Remove make every user visible
const users = userData.map((user: User) => {
user.visible = true;
return user;
});
return NextResponse.json(users, { status: data.status });
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}/${uuid}`);
return NextResponse.json(await data.json(), { status: data.status });
}

View File

@ -0,0 +1,58 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import User, { Role } from "@/utils/models/User";
export async function login(email: string, password: string) {
const supabase = createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email,
password,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
return "Incorrect email/password";
}
const supabaseUser = await supabase.auth.getUser();
if (!supabaseUser.data.user) {
revalidatePath("/home", "layout");
redirect("/home");
}
const apiData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${supabaseUser.data.user.id}`
);
const user: User = await apiData.json();
console.log(user);
if (user.role === Role.ADMIN) {
redirect("/admin");
}
revalidatePath("/home", "layout");
redirect("/home");
}
export async function signOut() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error || !data?.user) {
redirect("auth/login");
}
console.log(`Signed out ${data.user.email}!`);
await supabase.auth.signOut();
revalidatePath("/auth/login", "layout");
redirect("/auth/login");
}

View File

@ -0,0 +1,3 @@
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>;
}

View File

@ -1,25 +1,23 @@
// pages/forgot-password.tsx
"use client";
import React, { useState } from 'react';
import Input from '@/components/Input';
import Button from '@/components/Button';
import InlineLink from '@/components/InlineLink';
import ErrorBanner from '@/components/auth/ErrorBanner';
import React, { useState } from "react";
import Input from "@/components/Input";
import Button from "@/components/Button";
import InlineLink from "@/components/InlineLink";
import ErrorBanner from "@/components/auth/ErrorBanner";
export default function ForgotPasswordPage() {
const [confirmEmail, setConfirmEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim() === '') {
setEmailError('Email cannot be empty');
if (email.trim() === "") {
setEmailError("Email cannot be empty");
return false;
} else if (!emailRegex.test(email)) {
setEmailError('Invalid email format');
setEmailError("Invalid email format");
return false;
}
return true; // No error
@ -27,31 +25,30 @@ export default function ForgotPasswordPage() {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
isValidEmail(confirmEmail);
event.preventDefault();
}
};
return (
<>
<h1 className="font-bold text-xl text-purple-800">Forgot Password</h1>
<div className="mb-6">
<Input
type='email'
valid={emailError == null}
title="Enter your email address"
placeholder="janedoe@gmail.com"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/login">
Back to Sign In
</InlineLink>
<Button type="submit" onClick={handleClick}>
Send
</Button>
</div>
<h1 className="font-bold text-xl text-purple-800">
Forgot Password
</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError == null}
title="Enter your email address"
placeholder="janedoe@gmail.com"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/login">Back to Sign In</InlineLink>
<Button type="submit" onClick={handleClick}>
Send
</Button>
</div>
</>
);
}

View File

@ -1,22 +1,20 @@
import Paper from '@/components/auth/Paper';
import Paper from "@/components/auth/Paper";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
)
}
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
);
}

View File

@ -1,81 +1,118 @@
// pages/index.tsx
"use client";
import Button from '@/components/Button';
import Input from '@/components/Input'
import InlineLink from '@/components/InlineLink';
import Image from 'next/image';
import { useState } from "react";
import PasswordInput from '@/components/auth/PasswordInput';
import ErrorBanner from '@/components/auth/ErrorBanner';
import Button from "@/components/Button";
import Input from "@/components/Input";
import InlineLink from "@/components/InlineLink";
import Image from "next/image";
import { useEffect, useState } from "react";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
import { login } from "../actions";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/home");
}
}
checkUser();
}, [router]);
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
}
};
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handlePasswordChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setPassword(event.currentTarget.value);
}
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// Priority: Incorrect combo > Missing email > Missing password
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim().length === 0) {
setEmailError("Please enter your email.");
return;
}
if (!emailRegex.test(email)) {
setEmailError("Please enter a valid email address.");
return;
}
setEmailError("");
if (password.trim().length === 0) {
setEmailError("Please enter your password.")
event.preventDefault();
setPasswordError("Please enter your password.");
return;
}
// This shouldn't happen, <input type="email"> already provides validation, but just in case.
if (email.trim().length === 0) {
setPasswordError("Please enter your email.")
event.preventDefault();
setPasswordError("");
setIsLoading(true);
const error = await login(email, password);
setIsLoading(false);
if (error) {
setLoginError(error);
}
// Placeholder for incorrect email + password combo.
if (email === "incorrect@gmail.com" && password) {
setPasswordError("Incorrect password.")
event.preventDefault();
}
}
};
return (
<>
<Image
src="/logo.png"
alt='Compass Center logo.'
width={100}
height={91}
/>
<h1 className='font-bold text-2xl text-purple-800'>Login</h1>
<div className="mb-6">
<Input type='email' valid={emailError == ""} title="Email" placeholder="janedoe@gmail.com" onChange={handleEmailChange} required />
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError === ""}
title="Email"
placeholder="Enter Email"
onChange={handleEmailChange}
required
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput
title="Password"
placeholder="Enter Password"
valid={passwordError === ""}
onChange={handlePasswordChange}
/>
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick} disabled={isLoading}>
<div className="flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-white border-t-purple-500 rounded-full animate-spin mr-2"></div>
)}
{isLoading ? "Logging in..." : "Login"}
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput title="Password" valid={passwordError == ""} onChange={handlePasswordChange} />
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick}>
Login
</Button>
</div>
</Button>
</div>
{loginError && <ErrorBanner heading={loginError} />}
</>
);
};
}

View File

@ -1,62 +1,79 @@
// pages/index.tsx
"use client";
import { useState, useEffect } from 'react';
import Button from '@/components/Button';
import PasswordInput from '@/components/auth/PasswordInput';
import ErrorBanner from '@/components/auth/ErrorBanner';
import { useState, useEffect } from "react";
import Button from "@/components/Button";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
function isStrongPassword(password: string): boolean {
const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
return strongPasswordRegex.test(password);
const strongPasswordRegex =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
return strongPasswordRegex.test(password);
}
export default function Page() {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
useEffect(() => {
setIsButtonDisabled(newPassword === '' || confirmPassword === '' || newPassword !== confirmPassword|| !isStrongPassword(newPassword));
}, [newPassword, confirmPassword]);
useEffect(() => {
setIsButtonDisabled(
newPassword === "" ||
confirmPassword === "" ||
newPassword !== confirmPassword ||
!isStrongPassword(newPassword)
);
}, [newPassword, confirmPassword]);
return (
<>
<div className="text-center sm:text-left">
<h1 className="font-bold text-xl text-purple-800">New Password</h1>
</div>
<div className="mb-4">
<PasswordInput
title="Enter New Password"
value={newPassword}
valid={!isButtonDisabled || isStrongPassword(newPassword)}
onChange={(e) => {
setNewPassword(e.target.value);
}}
/>
</div>
{isStrongPassword(newPassword) || newPassword === '' ? null : <ErrorBanner heading="Password is not strong enough." description="Tip: Use a mix of letters, numbers, and symbols for a strong password. Aim for at least 8 characters!" />}
<div className="mb-6">
<PasswordInput
title="Confirm Password"
value={confirmPassword}
valid={!isButtonDisabled || (newPassword === confirmPassword && confirmPassword !== '')}
onChange={(e) => {
setConfirmPassword(e.target.value);
}}
/>
</div>
{newPassword === confirmPassword || confirmPassword === '' ? null : <ErrorBanner heading="Passwords do not match." description="Please make sure both passwords are the exact same!"/>}
<div className="flex flex-col items-left space-y-4">
<Button type="submit" disabled={isButtonDisabled} >
Send
</Button>
</div>
</>
);
return (
<>
<div className="text-center sm:text-left">
<h1 className="font-bold text-xl text-purple-800">
New Password
</h1>
</div>
<div className="mb-4">
<PasswordInput
title="Enter New Password"
value={newPassword}
valid={!isButtonDisabled || isStrongPassword(newPassword)}
onChange={(e) => {
setNewPassword(e.target.value);
}}
/>
</div>
{isStrongPassword(newPassword) || newPassword === "" ? null : (
<ErrorBanner
heading="Password is not strong enough."
description="Tip: Use a mix of letters, numbers, and symbols for a strong password. Aim for at least 8 characters!"
/>
)}
<div className="mb-6">
<PasswordInput
title="Confirm Password"
value={confirmPassword}
valid={
!isButtonDisabled ||
(newPassword === confirmPassword &&
confirmPassword !== "")
}
onChange={(e) => {
setConfirmPassword(e.target.value);
}}
/>
</div>
{newPassword === confirmPassword ||
confirmPassword === "" ? null : (
<ErrorBanner
heading="Passwords do not match."
description="Please make sure both passwords are the exact same!"
/>
)}
<div className="flex flex-col items-left space-y-4">
<Button type="submit" disabled={isButtonDisabled}>
Send
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import Sidebar from "@/components/Sidebar/Sidebar";
import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [user, setUser] = useState<User>();
const router = useRouter();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed home page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
setUser(await userData.json());
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
name={user.username}
email={user.email}
setIsSidebarOpen={setIsSidebarOpen}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

61
compass/app/home/page.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client";
import Callout from "@/components/resource/Callout";
import Card from "@/components/resource/Card";
import { LandingSearchBar } from "@/components/resource/LandingSearchBar";
import {
BookOpenIcon,
BookmarkIcon,
ClipboardIcon,
} from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
export default function Page() {
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-row">
<div className="mb-4 flex items-center space-x-4">
<Image
src="/logo.png"
alt="Compass Center logo."
width={25}
height={25}
/>
<h1 className="font-bold text-2xl text-purple-800">
Compass Center Advocate Landing Page
</h1>
</div>
<Callout>
Welcome! Below you will find a list of resources for the
Compass Center&apos;s trained advocates. These materials
serve to virtually provide a collection of advocacy,
resource, and hotline manuals and information.
<b>
{" "}
If you are an advocate looking for the contact
information of a particular Compass Center employee,
please directly contact your staff back-up or the person
in charge of your training.
</b>
</Callout>
</div>
<div className="p-8 flex-grow border-t border-gray-200 bg-gray-50">
{/* link to different pages */}
<div className="grid grid-cols-3 gap-6 pb-6">
<Link href="/resource">
<Card icon={<BookmarkIcon />} text="Resources" />
</Link>
<Link href="/service">
<Card icon={<ClipboardIcon />} text="Services" />
</Link>
<Link href="/training-manual">
<Card icon={<BookOpenIcon />} text="Training Manuals" />
</Link>
</div>
{/* search bar */}
<LandingSearchBar />
</div>
</div>
);
}

View File

@ -1,16 +1,20 @@
import '../styles/globals.css';
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"use client";
import "../styles/globals.css";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { createClient } from "@/utils/supabase/client";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@ -1,86 +1,11 @@
// pages/index.tsx
"use client";
import Button from '@/components/Button';
import Input from '@/components/Input'
import InlineLink from '@/components/InlineLink';
import Paper from '@/components/auth/Paper';
// import { Metadata } from 'next'
import Image from 'next/image';
import {ChangeEvent, useState} from "react";
import { useRouter } from "next/navigation";
// export const metadata: Metadata = {
// title: 'Login',
// }
export default function Page() {
const router = useRouter();
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
router.push("/auth/login");
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
console.log("email " + email);
}
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value);
console.log("password " + password)
}
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
// Priority: Incorrect combo > Missing email > Missing password
if (password.trim().length === 0) {
setError("Please enter your password.")
}
// This shouldn't happen, <input type="email"> already provides validation, but just in case.
if (email.trim().length === 0) {
setError("Please enter your email.")
}
// Placeholder for incorrect email + password combo.
if (email === "incorrect@gmail.com" && password) {
setError("Incorrect password.")
}
}
return (
<>
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
<Image
src="/logo.png"
alt='Compass Center logo.'
width={100}
height={91}
/>
<h1 className='font-bold text-xl text-purple-800'>Login</h1>
<div className="mb-4">
<Input type='email' title="Email" placeholder="janedoe@gmail.com" onChange={handleEmailChange} />
</div>
<div className="mb-6">
<Input type='password' title="Password" onChange={handlePasswordChange} />
</div>
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick}>
Login
</Button>
<div className="text-center text-red-600" hidden={!error}>
<p>{error}</p>
</div>
</div>
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
</>
);
};
return <h1>GO TO LOGIN PAGE (/auth/login)</h1>;
}

View File

@ -1,37 +1,92 @@
"use client"
"use client";
import Sidebar from '@/components/resource/Sidebar';
import React, { useState } from 'react';
import { ChevronDoubleRightIcon } from '@heroicons/react/24/outline';
import Sidebar from "@/components/Sidebar/Sidebar";
import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed resource page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={'Open sidebar'}
>
{!isSidebarOpen &&
<ChevronDoubleRightIcon className="h-5 w-5" /> // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div className={`absolute inset-y-0 left-0 transform ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} w-64 transition duration-300 ease-in-out`}>
<Sidebar setIsSidebarOpen={setIsSidebarOpen} />
</div>
{/* page ui */}
<div className={`flex-1 transition duration-300 ease-in-out ${isSidebarOpen ? 'ml-64' : 'ml-0'}`}>
{children}
</div>
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
)
}
);
}

View File

@ -1,39 +1,45 @@
"use client"
import Callout from "@/components/resource/Callout";
import Card from "@/components/resource/Card";
import { LandingSearchBar } from "@/components/resource/LandingSearchBar";
import { BookOpenIcon, BookmarkIcon, ClipboardIcon } from "@heroicons/react/24/solid";
import Image from 'next/image';
"use client";
import { PageLayout } from "@/components/PageLayout";
import { ResourceTable } from "@/components/Table/ResourceIndex";
import Resource from "@/utils/models/Resource";
import { createClient } from "@/utils/supabase/client";
import { BookmarkIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [resources, setResources] = useState<Resource[]>([]);
useEffect(() => {
async function getResources() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const userListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/resource/all?uuid=${data.user.id}`
);
const resourcesAPI: Resource[] = await userListData.json();
setResources(resourcesAPI);
}
getResources();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-grow">
<div className="mb-4 flex items-center space-x-4">
<Image
src="/logo.png"
alt='Compass Center logo.'
width={25}
height={25}
/>
<h1 className='font-bold text-2xl text-purple-800'>Compass Center Advocate Landing Page</h1>
</div>
<Callout>
Welcome! Below you will find a list of resources for the Compass Center's trained advocates. These materials serve to virtually provide a collection of advocacy, resource, and hotline manuals and information.
<b> If you are an advocate looking for the contact information of a particular Compass Center employee, please directly contact your staff back-up or the person in charge of your training.</b>
</Callout>
</div>
<div className="p-8 flex-grow border-t border-gray-200 bg-gray-50">
{/* link to different pages */}
<div className="grid grid-cols-3 gap-6 pb-6">
<Card icon={<BookmarkIcon />} text="Resources" />
<Card icon={<ClipboardIcon />} text="Services" />
<Card icon={<BookOpenIcon />} text="Training Manuals" />
</div>
{/* search bar */}
<LandingSearchBar />
</div>
<PageLayout title="Resources" icon={<BookmarkIcon />}>
<ResourceTable users={resources} />
</PageLayout>
</div>
)
);
}

View File

@ -0,0 +1,92 @@
"use client";
import Sidebar from "@/components/Sidebar/Sidebar";
import React, { useState } from "react";
import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed service page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { PageLayout } from "@/components/PageLayout";
import { ServiceTable } from "@/components/Table/ServiceIndex";
import Service from "@/utils/models/Service";
import { createClient } from "@/utils/supabase/client";
import { ClipboardIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [services, setUsers] = useState<Service[]>([]);
useEffect(() => {
async function getServices() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const serviceListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/service/all?uuid=${data.user.id}`
);
const servicesAPI: Service[] = await serviceListData.json();
setUsers(servicesAPI);
}
getServices();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Services" icon={<ClipboardIcon />}>
<ServiceTable users={services} />
</PageLayout>
</div>
);
}

View File

@ -0,0 +1,20 @@
import Paper from "@/components/auth/Paper";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode;
}) {
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
);
}

116
compass/app/test/page.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client";
import Button from "@/components/Button";
import Input from "@/components/Input";
import InlineLink from "@/components/InlineLink";
import Image from "next/image";
import { useEffect, useState } from "react";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
import { login } from "../auth/actions";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/home");
}
}
checkUser();
}, [router]);
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
};
const handlePasswordChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setPassword(event.currentTarget.value);
};
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim().length === 0) {
setEmailError("Please enter your email.");
return;
}
if (!emailRegex.test(email)) {
setEmailError("Please enter a valid email address.");
return;
}
setEmailError("");
if (password.trim().length === 0) {
setPasswordError("Please enter your password.");
return;
}
setPasswordError("");
setIsLoading(true);
const error = await login(email, password);
setIsLoading(false);
if (error) {
setLoginError(error);
}
};
return (
<>
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError === ""}
title="Email"
placeholder="Enter Email"
required
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput
title="Password"
placeholder="Enter Password"
valid={passwordError === ""}
onChange={handlePasswordChange}
/>
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick} disabled={isLoading}>
<div className="flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-white border-t-purple-500 rounded-full animate-spin mr-2"></div>
)}
{isLoading ? "Logging in..." : "Login"}
</div>
</Button>
</div>
{loginError && <ErrorBanner heading={loginError} />}
</>
);
}

View File

@ -0,0 +1,40 @@
// page.tsx
import React from "react";
import Head from "next/head";
import Link from "next/link";
const ComingSoonPage: React.FC = () => {
return (
<>
<Head>
<title>Training Manuals - Coming Soon</title>
<meta
name="description"
content="Our training manuals page is coming soon. Stay tuned for updates!"
/>
</Head>
<div className="min-h-screen bg-gradient-to-r from-purple-600 to-blue-500 flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">
Training Manuals
</h1>
<p className="text-xl text-white mb-8">
Our training manuals page is under construction.
</p>
<p className="text-lg text-white">
Stay tuned for updates!
</p>
<div className="mt-8">
<Link href="/home">
<button className="bg-white text-purple-600 font-semibold py-2 px-4 rounded-full shadow-md hover:bg-purple-100 transition duration-300">
Notify Me
</button>
</Link>
</div>
</div>
</div>
</>
);
};
export default ComingSoonPage;

View File

@ -1,15 +1,23 @@
import { FunctionComponent, ReactNode } from 'react';
import { FunctionComponent, ReactNode } from "react";
type ButtonProps = {
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
type?: "button" | "submit" | "reset";
disabled?: boolean;
};
const Button: FunctionComponent<ButtonProps> = ({ children, type, disabled, onClick}) => {
const buttonClassName = `inline-block rounded border ${disabled ? 'bg-gray-400 text-gray-600 cursor-not-allowed' : 'border-purple-600 bg-purple-600 text-white hover:bg-transparent hover:text-purple-600 focus:outline-none focus:ring active:text-purple-500'} px-4 py-1 text-md font-semibold w-20 h-10 text-center`;
const Button: FunctionComponent<ButtonProps> = ({
children,
type,
disabled,
onClick,
}) => {
const buttonClassName = `inline-flex items-center justify-center rounded border ${
disabled
? "bg-gray-400 text-gray-600 cursor-not-allowed"
: "border-purple-600 bg-purple-600 text-white hover:bg-transparent hover:text-purple-600 focus:outline-none focus:ring active:text-purple-500"
} px-4 py-2 text-md font-semibold w-full sm:w-auto`;
return (
<button
@ -18,7 +26,9 @@ const Button: FunctionComponent<ButtonProps> = ({ children, type, disabled, onCl
type={type}
disabled={disabled}
>
{children}
<div className="flex items-center justify-center space-x-2">
{children}
</div>
</button>
);
};

View File

@ -0,0 +1,247 @@
import { FunctionComponent, ReactNode } from "react";
import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import {
StarIcon as SolidStarIcon,
EnvelopeIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
StarIcon as OutlineStarIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index";
type DrawerProps = {
title: string;
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
disabled?: boolean;
editableContent?: any;
onSave?: (content: any) => void;
rowContent?: any;
onRowUpdate?: (content: any) => void;
};
interface EditContent {
content: string;
isEditing: boolean;
}
const Drawer: FunctionComponent<DrawerProps> = ({
title,
children,
onSave,
editableContent,
rowContent,
onRowUpdate,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent);
const handleTempRowContentChange = (e) => {
const { name, value } = e.target;
console.log(name);
console.log(value);
setTempRowContent((prevContent) => ({
...prevContent,
[name]: value,
}));
};
const handleEnterPress = (e) => {
if (e.key === "Enter") {
e.preventDefault();
// Update the rowContent with the temporaryRowContent
if (onRowUpdate) {
onRowUpdate(tempRowContent);
}
}
};
const toggleDrawer = () => {
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
const toggleFavorite = () => setIsFavorite(!isFavorite);
const drawerClassName = `fixed top-0 right-0 w-1/2 h-full bg-white transform ease-in-out duration-300 z-20 ${
isOpen ? "translate-x-0 shadow-xl" : "translate-x-full"
} ${isFull ? "w-full" : "w-1/2"}`;
const iconComponent = isFull ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
);
const favoriteIcon = isFavorite ? (
<SolidStarIcon className="h-5 w-5" />
) : (
<OutlineStarIcon className="h-5 w-5" />
);
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [rolePresetOptions, setRolePresetOptions] = useState([
"domestic",
"community",
"economic",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
return (
<div>
<button
className={
"ml-2 text-xs uppercase opacity-0 group-hover:opacity-100 text-gray-500 font-medium border border-gray-200 bg-white shadow hover:bg-gray-50 p-2 rounded-md"
}
onClick={toggleDrawer}
>
Open
</button>
<div className={drawerClassName}></div>
<div className={drawerClassName}>
<div className="flex items-center justify-between p-4">
<div className="flex flex-row items-center justify-between space-x-2">
<span className="h-5 text-purple-200 w-5">
<UserIcon />
</span>
<h2 className="text-lg text-gray-800 font-semibold">
{rowContent.username}
</h2>
</div>
<div>
<button
onClick={toggleFavorite}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{favoriteIcon}
</button>
<button
onClick={toggleDrawerFullScreen}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{iconComponent}
</button>
<button
onClick={toggleDrawer}
className="py-2 text-gray-500 hover:text-gray-800"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-4">
<table className="p-4">
<tbody className="items-center">
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<UserIcon className="h-4 w-4" />
</td>
<td className="w-32">Username</td>
</div>
<td className="w-3/4 w-3/4 p-2 pl-0">
<input
type="text"
name="username"
value={tempRowContent.username}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-full p-1 focus:outline-gray-200 hover:bg-gray-50"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Role</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
<TagsInput
presetValue={tempRowContent.role}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<EnvelopeIcon className="h-4 w-4" />
</td>
<td className="w-32">Email</td>
</div>
<td className="w-3/4 p-2 pl-0">
<input
type="text"
name="email"
value={tempRowContent.email}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-80 p-1 font-normal hover:text-gray-400 focus:outline-gray-200 hover:bg-gray-50 underline text-gray-500"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Type of Program</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
{/* {rowContent.program} */}
<TagsInput
presetValue={tempRowContent.program}
presetOptions={rolePresetOptions}
setPresetOptions={setRolePresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
</tbody>
</table>
<br />
</div>
</div>
</div>
);
};
export default Drawer;

View File

@ -0,0 +1,53 @@
import { useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
const mockTags = ["food relief", "period poverty", "nutrition education"];
type FilterType = "contains" | "does not contain" | "is empty" | "is not empty";
export const ContainsDropdown = ({
isDropdownOpen,
setIsDropdownOpen,
filterType,
setFilterType,
}) => {
const handleFilterTypeChange = (type: FilterType) => {
setFilterType(type);
setIsDropdownOpen(false);
};
return (
<div className="relative">
<div
className={`absolute z-10 mt-8 -top-28 bg-white border border-gray-300 rounded-md shadow-md p-2 ${
isDropdownOpen ? "block" : "hidden"
}`}
>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("contains")}
>
Contains
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("does not contain")}
>
Does not contain
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("is empty")}
>
Is empty
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("is not empty")}
>
Is not empty
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,95 @@
// FilterBox.tsx
import { useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { ContainsDropdown } from "./ContainsDropdown";
const mockTags = ["food relief", "period poverty", "nutrition education"];
type FilterType = "contains" | "does not contain" | "is empty" | "is not empty";
export const FilterBox = () => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [showContainsDropdown, setShowContainsDropdown] = useState(false);
const [filterType, setFilterType] = useState<FilterType>("contains");
const handleTagChange = (tag: string) => {
setSelectedTags((prevTags) =>
prevTags.includes(tag)
? prevTags.filter((t) => t !== tag)
: [...prevTags, tag]
);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
const renderSelectedTags = () =>
selectedTags.map((tag) => (
<div
key={tag}
className="bg-purple-100 text-purple-800 px-2 py-1 rounded-md flex items-center mr-2"
>
<span>{tag}</span>
<span
className="ml-2 cursor-pointer"
onClick={() => handleTagChange(tag)}
>
&times;
</span>
</div>
));
return (
<div className="text-xs bg-white border border-gray-300 z-50 rounded-md p-2 shadow absolute right-5 top-[200px]">
<div className="mb-2">
<span className="font-semibold">
Tags{" "}
<button
onClick={() =>
setShowContainsDropdown((prevState) => !prevState)
}
className="hover:bg-gray-50 text-gray-500 hover:text-gray-700"
>
{filterType} <ChevronDownIcon className="inline h-3" />
</button>
</span>
</div>
<div className="flex flex-wrap mb-2 px-2 py-1 border border-gray-300 rounded w-full">
{selectedTags.length > 0 && renderSelectedTags()}
<input
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Search tags..."
/>
</div>
<div className="max-h-48 overflow-y-auto">
{mockTags
.filter((tag) =>
tag.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((tag) => (
<div key={tag} className="flex items-center">
<input
type="checkbox"
checked={selectedTags.includes(tag)}
onChange={() => handleTagChange(tag)}
className="mr-2 accent-purple-500"
/>
<label>{tag}</label>
</div>
))}
</div>
{showContainsDropdown && (
<ContainsDropdown
isDropdownOpen={showContainsDropdown}
setIsDropdownOpen={setShowContainsDropdown}
filterType={filterType}
setFilterType={setFilterType}
/>
)}
</div>
);
};

View File

@ -1,16 +1,21 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
interface Link {
href?: string;
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
href?: string;
children: ReactNode;
}
const InlineLink: React.FC<Link> = ({href = '#', children}) => {
const InlineLink: React.FC<Link> = ({ href = "#", children, onClick }) => {
return (
<a href={href} className='text-sm text-purple-600 hover:underline font-semibold'>
<a
onClick={onClick}
href={href}
className="text-sm text-purple-600 hover:underline font-semibold"
>
{children}
</a>
)
}
);
};
export default InlineLink;
export default InlineLink;

View File

@ -1,37 +1,57 @@
import React, { FunctionComponent, InputHTMLAttributes, ReactNode, ChangeEvent } from 'react';
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
icon?: ReactNode;
title?: ReactNode;
type?:ReactNode;
placeholder?:ReactNode
valid?:boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const Input: FunctionComponent<InputProps> = ({ icon, type, title, placeholder, onChange, valid = true, ...rest }) => {
return (
<div>
<label
htmlFor={title}
className={valid ? "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-purple-600 focus-within:ring-1 focus-within:ring-purple-600" : "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-red-600 focus-within:ring-1 focus-within:ring-red-600"}
>
<span className="text-xs font-semibold text-gray-700"> {title} </span>
<div className="mt-1 flex items-center">
<input
type={type}
id={title}
placeholder={placeholder}
onChange={onChange}
className="w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm"
/>
<span className="inline-flex items-center px-3 text-gray-500">
{icon}
</span>
</div>
</label>
</div>
);
};
export default Input;
import React, {
FunctionComponent,
InputHTMLAttributes,
ReactNode,
ChangeEvent,
} from "react";
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
icon?: ReactNode;
title?: ReactNode;
type?: ReactNode;
placeholder?: ReactNode;
valid?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const Input: FunctionComponent<InputProps> = ({
icon,
type,
title,
placeholder,
onChange,
valid = true,
...rest
}) => {
return (
<div>
<label
htmlFor={title}
className={
valid
? "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-purple-600 focus-within:ring-1 focus-within:ring-purple-600"
: "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-red-600 focus-within:ring-1 focus-within:ring-red-600"
}
>
<span className="text-xs font-semibold text-gray-700">
{" "}
{title}{" "}
</span>
<div className="mt-1 flex items-center">
<input
type={type}
id={title}
placeholder={placeholder}
onChange={onChange}
className="w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm"
/>
<span className="inline-flex items-center px-3 text-gray-500">
{icon}
</span>
</div>
</label>
</div>
);
};
export default Input;

View File

@ -0,0 +1,27 @@
interface PageLayoutProps {
icon: React.ReactElement;
title: string;
children: React.ReactElement;
}
export const PageLayout: React.FC<PageLayoutProps> = ({
icon,
title,
children,
}) => {
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-row">
<div className="mb-4 flex items-center space-x-4">
<span className="w-6 h-6 text-purple-200">{icon}</span>
<h1 className="font-bold text-2xl text-purple-800">
{title}
</h1>
</div>
</div>
{/* data */}
<div className="px-8 py-8">{children}</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
import React from "react";
import {
HomeIcon,
ChevronDoubleLeftIcon,
BookmarkIcon,
ClipboardIcon,
BookOpenIcon,
LockClosedIcon,
} from "@heroicons/react/24/solid";
import { SidebarItem } from "./SidebarItem";
import { UserProfile } from "../resource/UserProfile";
interface SidebarProps {
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
name: string;
email: string;
isAdmin: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({
setIsSidebarOpen,
name,
email,
isAdmin: admin,
}) => {
return (
<div className="w-64 h-full border border-gray-200 bg-gray-50 px-4">
{/* button to close sidebar */}
<div className="flex justify-end">
<button
onClick={() => setIsSidebarOpen(false)}
className="py-2 text-gray-500 hover:text-gray-800"
aria-label="Close sidebar"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col space-y-8">
{/* user + logout button */}
<div className="flex items-center p-4 space-x-2 border border-gray-200 rounded-md ">
<UserProfile name={name} email={email} />
</div>
{/* navigation menu */}
<div className="flex flex-col space-y-2">
<h4 className="text-xs font-semibold text-gray-500">
Pages
</h4>
<nav className="flex flex-col">
{admin && (
<SidebarItem
icon={<LockClosedIcon />}
text="Admin"
active={true}
redirect="/admin"
/>
)}
<SidebarItem
icon={<HomeIcon />}
text="Home"
active={true}
redirect="/home"
/>
<SidebarItem
icon={<BookmarkIcon />}
text="Resources"
active={true}
redirect="/resource"
/>
<SidebarItem
icon={<ClipboardIcon />}
text="Services"
active={true}
redirect="/service"
/>
<SidebarItem
icon={<BookOpenIcon />}
text="Training Manuals"
active={true}
redirect="/training-manuals"
/>
</nav>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,31 @@
import Link from "next/link";
interface SidebarItemProps {
icon: React.ReactElement;
text: string;
active: boolean;
redirect: string;
}
export const SidebarItem: React.FC<SidebarItemProps> = ({
icon,
text,
active,
redirect,
}) => {
return (
<Link
href={redirect}
className={
active
? "flex items-center p-2 my-1 space-x-2 bg-gray-200 rounded-md"
: "flex items-center p-2 my-1 space-x-2 hover:bg-gray-200 rounded-md"
}
>
<span className="h-5 text-gray-500 w-5">{icon}</span>
<span className="flex-grow font-medium text-xs text-gray-500">
{text}
</span>
</Link>
);
};

View File

@ -0,0 +1,306 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import User from "@/utils/models/User";
// For search
const fuzzyFilter = (
row: Row<any>,
columnId: string,
value: any,
addMeta: (meta: any) => void
) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value);
// Store the ranking info
addMeta(itemRank);
// Return if the item should be filtered in/out
return itemRank.passed;
};
export const Table = ({ users }: { users: User[] }) => {
const columnHelper = createColumnHelper<User>();
useEffect(() => {
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
);
};
const hideUser = (userId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`);
setData((currentData) => {
const newData = currentData
.map((user) => {
if (user.id === userId) {
return { ...user, visible: !user.visible };
}
return user;
})
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData);
return newData;
});
};
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
const columns = [
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => deleteUser(props.row.original.id)}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("username", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Username
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("role", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Role
</>
),
cell: (info) => (
<TagsInput
presetValue={info.getValue()}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
),
}),
columnHelper.accessor("email", {
header: () => (
<>
<AtSymbolIcon className="inline align-top h-4" /> Email
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500 underline hover:text-gray-400">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
];
const [data, setData] = useState<User[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching
const [query, setQuery] = useState("");
const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
// added this fn for editing rows
const handleRowUpdate = (updatedRow: User) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({
columns,
data,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
globalFilter: query,
},
onGlobalFilterChange: setQuery,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
// Individual row
const isUserVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : ""
}`;
return (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,33 @@
/* An extension of TableCell.tsx that includes an "open" button and the drawer.
For cells in the "primary" (or first) column of the table. */
import Drawer from "@/components/Drawer/Drawer";
import { TableCell } from "./TableCell";
import { SetStateAction, useState } from "react";
export const PrimaryTableCell = ({ getValue, row, column, table }) => {
const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent: SetStateAction<string>) => {
setPageContent(newContent);
};
return (
<div className="font-semibold group">
<TableCell
getValue={getValue}
row={row}
column={column}
table={table}
/>
<span className="absolute right-1 top-1">
<Drawer
title={getValue()}
editableContent={pageContent}
onSave={handleDrawerContentChange}
>
{pageContent}
</Drawer>
</span>
</div>
);
};

View File

@ -0,0 +1,304 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import Resource from "@/utils/models/Resource";
// For search
const fuzzyFilter = (
row: Row<any>,
columnId: string,
value: any,
addMeta: (meta: any) => void
) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value);
// Store the ranking info
addMeta(itemRank);
// Return if the item should be filtered in/out
return itemRank.passed;
};
// TODO: Rename everything to resources
export const ResourceTable = ({ users }: { users: Resource[] }) => {
const columnHelper = createColumnHelper<Resource>();
useEffect(() => {
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
);
};
const hideUser = (userId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`);
setData((currentData) => {
const newData = currentData
.map((user) => {
if (user.id === userId) {
return { ...user, visible: !user.visible };
}
return user;
})
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData);
return newData;
});
};
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
const columns = [
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("link", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Link
</>
),
cell: (info) => (
<a
href={info.getValue()}
target={"_blank"}
className="ml-2 text-gray-500 underline hover:text-gray-400"
>
{info.getValue()}
</a>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Resource[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching
const [query, setQuery] = useState("");
const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
// added this fn for editing rows
const handleRowUpdate = (updatedRow: Resource) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({
columns,
data,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
globalFilter: query,
},
onGlobalFilterChange: setQuery,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
// Individual row
const isUserVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : ""
}`;
return (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,28 @@
import Drawer from "@/components/Drawer/Drawer";
import { ChangeEvent, useState } from "react";
export const RowOpenAction = ({ title, rowData, onRowUpdate }) => {
const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent) => {
setPageContent(newContent);
};
return (
<div className="font-semibold group flex flex-row items-center justify-between pr-2">
{title}
<span>
{/* Added OnRowUpdate to drawer */}
<Drawer
title="My Drawer Title"
editableContent={pageContent}
rowContent={rowData}
onSave={handleDrawerContentChange}
onRowUpdate={onRowUpdate}
>
{pageContent}
</Drawer>
</span>
</div>
);
};

View File

@ -0,0 +1,18 @@
import React from "react";
import {
TrashIcon,
DocumentDuplicateIcon,
ArrowUpRightIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
export const RowOption = ({ icon: Icon, label, onClick }) => {
return (
<button
onClick={onClick}
className="hover:bg-gray-100 flex items-center gap-2 p-2 w-full"
>
<Icon className="inline h-4" /> {label}
</button>
);
};

View File

@ -0,0 +1,46 @@
//delete, duplicate, open
import {
TrashIcon,
DocumentDuplicateIcon,
ArrowUpRightIcon,
EllipsisVerticalIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
import Button from "../Button";
import { useState, useEffect, useRef } from "react";
import { RowOption } from "./RowOption";
export const RowOptionMenu = ({ onDelete, onHide }) => {
const [menuOpen, setMenuOpen] = useState(false);
const openMenu = () => setMenuOpen(true);
const closeMenu = () => setMenuOpen(false);
// TODO: Hide menu if clicked elsewhere
return (
<>
<button
className="items-end"
onClick={() => setMenuOpen(!menuOpen)}
>
<EllipsisVerticalIcon className="h-4" />
</button>
<div
className={
"justify-start border border-gray-200 shadow-lg flex flex-col absolute bg-white w-auto p-2 rounded [&>*]:rounded z-10" +
(!menuOpen ? " invisible" : "")
}
>
<RowOption icon={TrashIcon} label="Delete" onClick={onDelete} />
<RowOption
icon={ArrowUpRightIcon}
label="Open"
onClick={() => {
/* handle open */
}}
/>
<RowOption icon={EyeSlashIcon} label="Hide" onClick={onHide} />
</div>
</>
);
};

View File

@ -0,0 +1,312 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import Service from "@/utils/models/Service";
// For search
const fuzzyFilter = (
row: Row<any>,
columnId: string,
value: any,
addMeta: (meta: any) => void
) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value);
// Store the ranking info
addMeta(itemRank);
// Return if the item should be filtered in/out
return itemRank.passed;
};
// TODO: Rename everything to service
export const ServiceTable = ({ users }: { users: Service[] }) => {
const columnHelper = createColumnHelper<Service>();
useEffect(() => {
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
);
};
const hideUser = (userId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`);
setData((currentData) => {
const newData = currentData
.map((user) => {
if (user.id === userId) {
return { ...user, visible: !user.visible };
}
return user;
})
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData);
return newData;
});
};
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
const columns = [
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("status", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Status
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("requirements", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Requirements
</>
),
cell: (info) => (
<TagsInput
presetValue={
info.getValue()[0] !== "" ? info.getValue() : ["N/A"]
}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Service[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching
const [query, setQuery] = useState("");
const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
// added this fn for editing rows
const handleRowUpdate = (updatedRow: Service) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({
columns,
data,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
globalFilter: query,
},
onGlobalFilterChange: setQuery,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
// Individual row
const isUserVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : ""
}`;
return (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,69 @@
// TableAction.tsx
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import { ChangeEventHandler, FunctionComponent, useRef, useState } from "react";
import { FilterBox } from "../FilterBox";
type TableActionProps = {
query: string;
handleChange: ChangeEventHandler<HTMLInputElement>;
};
export const TableAction: FunctionComponent<TableActionProps> = ({
query,
handleChange,
}) => {
const searchInput = useRef<HTMLInputElement>(null);
const [searchActive, setSearchActive] = useState(false);
const [showFilterBox, setShowFilterBox] = useState(false);
const activateSearch = () => {
setSearchActive(true);
if (searchInput.current === null) {
return;
}
searchInput.current.focus();
searchInput.current.addEventListener("focusout", () => {
if (searchInput.current?.value.trim() === "") {
searchInput.current.value = "";
deactivateSearch();
}
});
};
const deactivateSearch = () => setSearchActive(false);
const toggleFilterBox = () => setShowFilterBox((prev) => !prev);
return (
<div className="w-auto flex flex-row gap-x-0.5 items-center justify-between text-xs font-medium text-gray-500 p-2">
<span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50"
onClick={toggleFilterBox}
>
Filter
</span>
{showFilterBox && <FilterBox />}
<span className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100">
Sort
</span>
<span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100"
onClick={activateSearch}
>
<MagnifyingGlassIcon className="w-4 h-4 inline" />
</span>
<input
ref={searchInput}
className={
"outline-none transition-all duration-300 " +
(searchActive ? "w-48" : "w-0")
}
type="text"
name="search"
placeholder="Type to search..."
value={query ?? ""}
onChange={handleChange}
/>
</div>
);
};

View File

@ -0,0 +1,29 @@
/* A lone table cell. Passed in for "cell" for a TanStack Table. */
import { useState, useEffect } from "react";
export const TableCell = ({ getValue, row, column, table }) => {
const initialValue = getValue();
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const onBlur = () => {
table.options.meta?.updateData(row.index, column.id, value);
};
// focus:border focus:border-gray-200
const className =
"w-full p-3 bg-inherit rounded-md outline-none border border-transparent relative " +
"focus:shadow-md focus:border-gray-200 focus:bg-white focus:z-20 focus:p-4 focus:-m-1 " +
"focus:w-[calc(100%+0.5rem)]";
return (
<input
className={className}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={onBlur}
/>
);
};

View File

@ -0,0 +1,222 @@
[
{
"id": 0,
"created_at": 1711482132230,
"username": "Bo_Pfeffer",
"role": "ADMIN",
"email": "Bo.Pfeffer@gmail.com",
"program": "DOMESTIC",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 1,
"created_at": 1711482132231,
"username": "Marianna_Heathcote76",
"role": "ADMIN",
"email": "Marianna_Heathcote14@yahoo.com",
"program": "DOMESTIC",
"experience": 1,
"group": "",
"visible": true
},
{
"id": 2,
"created_at": 1711482132231,
"username": "Queenie_Schroeder",
"role": "VOLUNTEER",
"email": "Queenie_Schroeder@yahoo.com",
"program": "COMMUNITY",
"experience": 5,
"group": "",
"visible": true
},
{
"id": 3,
"created_at": 1711482132231,
"username": "Arne.Bode",
"role": "VOLUNTEER",
"email": "Arne.Bode@hotmail.com",
"program": "DOMESTIC",
"experience": 3,
"group": "",
"visible": true
},
{
"id": 4,
"created_at": 1711482132231,
"username": "Maia.Zulauf9",
"role": "ADMIN",
"email": "Maia_Zulauf@gmail.com",
"program": "DOMESTIC",
"experience": 5,
"group": "",
"visible": true
},
{
"id": 5,
"created_at": 1711482132231,
"username": "River_Bauch",
"role": "EMPLOYEE",
"email": "River.Bauch@yahoo.com",
"program": "ECONOMIC",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 6,
"created_at": 1711482132231,
"username": "Virgil.Hilll",
"role": "VOLUNTEER",
"email": "Virgil.Hilll@yahoo.com",
"program": "ECONOMIC",
"experience": 3,
"group": "",
"visible": true
},
{
"id": 7,
"created_at": 1711482132231,
"username": "Bridget_Cartwright",
"role": "ADMIN",
"email": "Bridget_Cartwright@yahoo.com",
"program": "ECONOMIC",
"experience": 3,
"group": "",
"visible": true
},
{
"id": 8,
"created_at": 1711482132231,
"username": "Glennie_Keebler64",
"role": "EMPLOYEE",
"email": "Glennie_Keebler60@yahoo.com",
"program": "DOMESTIC",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 9,
"created_at": 1711482132232,
"username": "Orin.Jenkins53",
"role": "EMPLOYEE",
"email": "Orin.Jenkins@gmail.com",
"program": "ECONOMIC",
"experience": 1,
"group": "",
"visible": true
},
{
"id": 10,
"created_at": 1711482132232,
"username": "Zachery.Rosenbaum",
"role": "ADMIN",
"email": "Zachery.Rosenbaum@hotmail.com",
"program": "COMMUNITY",
"experience": 3,
"group": "",
"visible": true
},
{
"id": 11,
"created_at": 1711482132232,
"username": "Phoebe.Ziemann",
"role": "EMPLOYEE",
"email": "Phoebe_Ziemann92@gmail.com",
"program": "COMMUNITY",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 12,
"created_at": 1711482132232,
"username": "Bradford_Conroy53",
"role": "VOLUNTEER",
"email": "Bradford_Conroy94@hotmail.com",
"program": "COMMUNITY",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 13,
"created_at": 1711482132232,
"username": "Florine_Strosin55",
"role": "VOLUNTEER",
"email": "Florine.Strosin29@hotmail.com",
"program": "ECONOMIC",
"experience": 1,
"group": "",
"visible": true
},
{
"id": 14,
"created_at": 1711482132232,
"username": "Constance.Doyle59",
"role": "EMPLOYEE",
"email": "Constance_Doyle@hotmail.com",
"program": "DOMESTIC",
"experience": 3,
"group": "",
"visible": true
},
{
"id": 15,
"created_at": 1711482132232,
"username": "Chauncey_Lockman",
"role": "ADMIN",
"email": "Chauncey_Lockman@yahoo.com",
"program": "DOMESTIC",
"experience": 5,
"group": "",
"visible": true
},
{
"id": 16,
"created_at": 1711482132232,
"username": "Esther_Wuckert-Larson26",
"role": "EMPLOYEE",
"email": "Esther_Wuckert-Larson@gmail.com",
"program": "ECONOMIC",
"experience": 0,
"group": "",
"visible": true
},
{
"id": 17,
"created_at": 1711482132232,
"username": "Jewel.Kunde",
"role": "VOLUNTEER",
"email": "Jewel_Kunde29@gmail.com",
"program": "ECONOMIC",
"experience": 5,
"group": "",
"visible": true
},
{
"id": 18,
"created_at": 1711482132232,
"username": "Hildegard_Parker92",
"role": "ADMIN",
"email": "Hildegard_Parker74@yahoo.com",
"program": "ECONOMIC",
"experience": 2,
"group": "",
"visible": true
},
{
"id": 19,
"created_at": 1711482132232,
"username": "Jordane.Lakin2",
"role": "ADMIN",
"email": "Jordane_Lakin@hotmail.com",
"program": "COMMUNITY",
"experience": 1,
"group": "",
"visible": true
}
]

View File

@ -0,0 +1,12 @@
import { Tag } from "./Tag";
export const CreateNewTagAction = ({ input }) => {
return (
<div className="flex flex-row space-x-2 hover:bg-gray-100 rounded-md py-2 p-2 items-center">
<p className="capitalize">Create</p>
<Tag active={false} onDelete={null}>
{input}
</Tag>
</div>
);
};

View File

@ -0,0 +1,49 @@
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
export const DropdownAction = ({ tag, handleDeleteTag, handleEditTag }) => {
const [isVisible, setVisible] = useState(false);
const [inputValue, setInputValue] = useState(tag);
const editTagOption = (e) => {
if (e.key === "Enter") {
handleEditTag(tag, inputValue);
setVisible(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
return (
<div>
<EllipsisHorizontalIcon
className="w-5 text-gray-500"
onClick={() => setVisible(!isVisible)}
/>
{isVisible && (
<div className="absolute flex flex-col justify-start z-50 rounded-md bg-white border border-gray-200 shadow p-2 space-y-2">
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={editTagOption}
autoFocus
className="bg-gray-50 text-2xs focus:outline-none rounded-md font-normal text-gray-800 p-1 border-2 focus:border-blue-200"
/>
<button
onClick={() => {
handleDeleteTag(inputValue);
setVisible(false);
}}
className="justify-start flex flex-row space-x-4 hover:bg-gray-100 rounded-md items-center p-2 px-2"
>
<TrashIcon className="w-3 h-3" />
<p>Delete</p>
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,174 @@
import React, { useState, useRef } from "react";
import "tailwindcss/tailwind.css";
import { TagsArray } from "./TagsArray";
import { TagDropdown } from "./TagDropdown";
import { CreateNewTagAction } from "./CreateNewTagAction";
interface TagsInputProps {
presetOptions: string[];
presetValue: string | string[];
setPresetOptions: () => {};
getTagColor: () => {};
}
const TagsInput: React.FC<TagsInputProps> = ({
presetValue,
presetOptions,
setPresetOptions,
getTagColor,
}) => {
const [inputValue, setInputValue] = useState("");
const [cellSelected, setCellSelected] = useState(false);
const [tags, setTags] = useState<Set<string>>(
typeof presetValue === "string"
? new Set([presetValue])
: new Set(presetValue)
);
const [options, setOptions] = useState<Set<string>>(new Set(presetOptions));
const dropdown = useRef<HTMLDivElement>(null);
const handleClick = () => {
if (!cellSelected) {
setCellSelected(true);
// Add event listener only after setting cellSelected to true
setTimeout(() => {
window.addEventListener("click", handleOutsideClick);
}, 100);
}
};
// TODO: Fix MouseEvent type and remove the as Node as that is completely wrong
const handleOutsideClick = (event: MouseEvent) => {
if (
dropdown.current &&
!dropdown.current.contains(event.target as Node)
) {
setCellSelected(false);
// Remove event listener after handling outside click
window.removeEventListener("click", handleOutsideClick);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setOptions(() => {
const newOptions = presetOptions.filter((item) =>
item.includes(e.target.value.toLowerCase())
);
return new Set(newOptions);
});
setInputValue(e.target.value); // Update input value state
};
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
// setPresetOptions((prevPreset) => {
// const uniqueSet = new Set(presetOptions);
// uniqueSet.add(inputValue);
// return Array.from(uniqueSet);
// });
setTags((prevTags) => new Set(prevTags).add(inputValue));
setOptions((prevOptions) => new Set(prevOptions).add(inputValue));
setInputValue("");
}
};
const handleSelectTag = (tagToAdd: string) => {
if (!tags.has(tagToAdd)) {
// Corrected syntax for checking if a Set contains an item
setTags((prevTags) => new Set(prevTags).add(tagToAdd));
}
};
const handleDeleteTag = (tagToDelete: string) => {
setTags((prevTags) => {
const updatedTags = new Set(prevTags);
updatedTags.delete(tagToDelete);
return updatedTags;
});
};
const handleDeleteTagOption = (tagToDelete: string) => {
// setPresetOptions(presetOptions.filter(tag => tag !== tagToDelete));
setOptions((prevOptions) => {
const updatedOptions = new Set(prevOptions);
updatedOptions.delete(tagToDelete);
return updatedOptions;
});
if (tags.has(tagToDelete)) {
handleDeleteTag(tagToDelete);
}
};
const handleEditTag = (oldTag: string, newTag: string) => {
if (oldTag !== newTag) {
setTags((prevTags) => {
const tagsArray = Array.from(prevTags);
const oldTagIndex = tagsArray.indexOf(oldTag);
if (oldTagIndex !== -1) {
tagsArray.splice(oldTagIndex, 1, newTag);
}
return new Set(tagsArray);
});
setOptions((prevOptions) => {
const optionsArray = Array.from(prevOptions);
const oldTagIndex = optionsArray.indexOf(oldTag);
if (oldTagIndex !== -1) {
optionsArray.splice(oldTagIndex, 1, newTag);
}
return new Set(optionsArray);
});
}
};
return (
<div className="cursor-pointer" onClick={handleClick}>
{!cellSelected ? (
<TagsArray
active={true}
handleDelete={handleDeleteTag}
tags={tags}
/>
) : (
<div ref={dropdown}>
<div className="absolute w-64 z-50 ml-1 mt-5">
<div className="rounded-md border border-gray-200 shadow">
<div className="flex flex-wrap rounded-t-md items-center gap-2 bg-gray-50 p-2">
<TagsArray
handleDelete={handleDeleteTag}
active
tags={tags}
/>
<input
type="text"
value={inputValue}
placeholder="Search for an option..."
onChange={handleInputChange}
onKeyDown={handleAddTag}
className="focus:outline-none bg-transparent"
autoFocus
/>
</div>
<div className="flex rounded-b-md bg-white flex-col border-t border-gray-100 text-2xs font-medium text-gray-500 p-2">
<p className="capitalize">
Select an option or create one
</p>
<TagDropdown
handleDeleteTag={handleDeleteTagOption}
handleEditTag={handleEditTag}
handleAdd={handleSelectTag}
tags={options}
/>
{inputValue.length > 0 && (
<CreateNewTagAction input={inputValue} />
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default TagsInput;

View File

View File

@ -0,0 +1,17 @@
import { XMarkIcon } from "@heroicons/react/24/solid";
import React, { useState, useEffect } from "react";
export const Tag = ({ children, handleDelete, active = false }) => {
return (
<span
className={`font-normal bg-purple-100 text-gray-800 flex flex-row p-1 px-2 rounded-lg`}
>
{children}
{active && handleDelete && (
<button onClick={() => handleDelete(children)}>
<XMarkIcon className={`ml-1 w-3 text-purple-500`} />
</button>
)}
</span>
);
};

View File

@ -0,0 +1,29 @@
import { Tag } from "./Tag";
import { DropdownAction } from "./DropdownAction";
export const TagDropdown = ({
tags,
handleEditTag,
handleDeleteTag,
handleAdd,
}) => {
return (
<div className="z-50 flex flex-col space-y-2 mt-2">
{Array.from(tags).map((tag, index) => (
<div
key={index}
className="items-center rounded-md p-1 flex flex-row justify-between hover:bg-gray-100"
>
<button onClick={() => handleAdd(tag)}>
<Tag>{tag}</Tag>
</button>
<DropdownAction
handleDeleteTag={handleDeleteTag}
handleEditTag={handleEditTag}
tag={tag}
/>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,27 @@
import { Tag } from "./Tag";
export interface Tags {
tags: Set<string>;
handleDelete: (tag: string) => void;
active: boolean;
}
export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => {
console.log(tags);
return (
<div className="flex ml-2 flex-wrap gap-2 items-center">
{Array.from(tags).map((tag, index) => {
return (
<Tag
handleDelete={handleDelete}
active={active}
key={index}
>
{tag}
</Tag>
);
})}
</div>
);
};

View File

@ -1,17 +1,27 @@
import React from 'react';
import React from "react";
interface ErrorBannerProps {
heading: string;
description?: string | null;
}
const ErrorBanner: React.FC<ErrorBannerProps> = ({ heading, description = null }) => {
const ErrorBanner: React.FC<ErrorBannerProps> = ({
heading,
description = null,
}) => {
return (
<div role="alert" className="rounded border-s-4 border-red-500 bg-red-50 p-4">
<strong className="block text-sm font-semibold text-red-800">{heading}</strong>
{description && <p className="mt-2 text-xs font-thin text-red-700">
{description}
</p>}
<div
role="alert"
className="rounded border-s-4 border-red-500 bg-red-50 p-4"
>
<strong className="block text-sm font-semibold text-red-800">
{heading}
</strong>
{description && (
<p className="mt-2 text-xs font-thin text-red-700">
{description}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,43 @@
/* components/Loading.module.css */
.loadingOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loadingContent {
text-align: center;
}
.loadingTitle {
font-size: 2rem;
font-weight: bold;
color: #5b21b6;
margin-top: 1rem;
}
.loadingSpinner {
width: 50px;
height: 50px;
border: 4px solid #5b21b6;
border-top: 4px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 2rem auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,22 @@
// components/Loading.js
import styles from "./Loading.module.css";
import Image from "next/image";
const Loading = () => {
return (
<div className={styles.loadingOverlay}>
<div className={styles.loadingContent}>
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className={styles.loadingTitle}>Loading...</h1>
<div className={styles.loadingSpinner}></div>
</div>
</div>
);
};
export default Loading;

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
interface PageInterface {
children: ReactNode;
@ -12,4 +12,4 @@ const Paper: React.FC<PageInterface> = ({ children }) => {
);
};
export default Paper;
export default Paper;

View File

@ -1,35 +1,46 @@
import React, { useState, FunctionComponent, ChangeEvent, ReactNode } from 'react';
import Input from '../Input'; // Adjust the import path as necessary
import { Icons } from '@/utils/constants';
import React, {
useState,
FunctionComponent,
ChangeEvent,
ReactNode,
} from "react";
import Input from "../Input"; // Adjust the import path as necessary
import { Icons } from "@/utils/constants";
type PasswordInputProps = {
title?: ReactNode; // Assuming you might want to reuse title, placeholder etc.
placeholder?: ReactNode;
valid?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
title?: ReactNode; // Assuming you might want to reuse title, placeholder etc.
placeholder?: ReactNode;
valid?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const PasswordInput: FunctionComponent<PasswordInputProps> = ({ onChange, valid = true, ...rest }) => {
const [visible, setVisible] = useState(false);
const PasswordInput: FunctionComponent<PasswordInputProps> = ({
onChange,
valid = true,
...rest
}) => {
const [visible, setVisible] = useState(false);
const toggleVisibility = () => {
setVisible(!visible);
};
const toggleVisibility = () => {
setVisible(!visible);
};
const PasswordIcon = visible ? Icons['HidePasswordIcon'] : Icons['UnhidePasswordIcon'];
const PasswordIcon = visible
? Icons["HidePasswordIcon"]
: Icons["UnhidePasswordIcon"];
// Render the Input component and pass the PasswordIcon as an icon prop
return (
<Input
{...rest}
type={visible ? "text" : "password"}
onChange={onChange}
valid={valid}
icon={
<PasswordIcon className="h-5 w-5" onClick={toggleVisibility} />
}
/>
);
// Render the Input component and pass the PasswordIcon as an icon prop
return (
<Input
{...rest}
type={visible ? "text" : "password"}
onChange={onChange}
valid={valid}
icon={
<PasswordIcon className="h-5 w-5" onClick={toggleVisibility} />
}
/>
);
};
export default PasswordInput;

View File

@ -0,0 +1,247 @@
import { FunctionComponent, ReactNode } from "react";
import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import {
StarIcon as SolidStarIcon,
EnvelopeIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
StarIcon as OutlineStarIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index";
type DrawerProps = {
title: string;
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
disabled?: boolean;
editableContent?: any;
onSave?: (content: any) => void;
rowContent?: any;
onRowUpdate?: (content: any) => void;
};
interface EditContent {
content: string;
isEditing: boolean;
}
const Drawer: FunctionComponent<DrawerProps> = ({
title,
children,
onSave,
editableContent,
rowContent,
onRowUpdate,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent);
const handleTempRowContentChange = (e) => {
const { name, value } = e.target;
console.log(name);
console.log(value);
setTempRowContent((prevContent) => ({
...prevContent,
[name]: value,
}));
};
const handleEnterPress = (e) => {
if (e.key === "Enter") {
e.preventDefault();
// Update the rowContent with the temporaryRowContent
if (onRowUpdate) {
onRowUpdate(tempRowContent);
}
}
};
const toggleDrawer = () => {
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
const toggleFavorite = () => setIsFavorite(!isFavorite);
const drawerClassName = `fixed top-0 right-0 w-1/2 h-full bg-white transform ease-in-out duration-300 z-20 ${
isOpen ? "translate-x-0 shadow-xl" : "translate-x-full"
} ${isFull ? "w-full" : "w-1/2"}`;
const iconComponent = isFull ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
);
const favoriteIcon = isFavorite ? (
<SolidStarIcon className="h-5 w-5" />
) : (
<OutlineStarIcon className="h-5 w-5" />
);
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [rolePresetOptions, setRolePresetOptions] = useState([
"domestic",
"community",
"economic",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
return (
<div>
<button
className={
"ml-2 text-xs uppercase opacity-0 group-hover:opacity-100 text-gray-500 font-medium border border-gray-200 bg-white shadow hover:bg-gray-50 p-2 rounded-md"
}
onClick={toggleDrawer}
>
Open
</button>
<div className={drawerClassName}></div>
<div className={drawerClassName}>
<div className="flex items-center justify-between p-4">
<div className="flex flex-row items-center justify-between space-x-2">
<span className="h-5 text-purple-200 w-5">
<UserIcon />
</span>
<h2 className="text-lg text-gray-800 font-semibold">
{rowContent.username}
</h2>
</div>
<div>
<button
onClick={toggleFavorite}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{favoriteIcon}
</button>
<button
onClick={toggleDrawerFullScreen}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{iconComponent}
</button>
<button
onClick={toggleDrawer}
className="py-2 text-gray-500 hover:text-gray-800"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-4">
<table className="p-4">
<tbody className="items-center">
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<UserIcon className="h-4 w-4" />
</td>
<td className="w-32">Username</td>
</div>
<td className="w-3/4 w-3/4 p-2 pl-0">
<input
type="text"
name="username"
value={tempRowContent.username}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-full p-1 focus:outline-gray-200 hover:bg-gray-50"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Role</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
<TagsInput
presetValue={tempRowContent.role}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<EnvelopeIcon className="h-4 w-4" />
</td>
<td className="w-32">Email</td>
</div>
<td className="w-3/4 p-2 pl-0">
<input
type="text"
name="email"
value={tempRowContent.email}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-80 p-1 font-normal hover:text-gray-400 focus:outline-gray-200 hover:bg-gray-50 underline text-gray-500"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Type of Program</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
{/* {rowContent.program} */}
<TagsInput
presetValue={tempRowContent.program}
presetOptions={rolePresetOptions}
setPresetOptions={setRolePresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
</tbody>
</table>
<br />
</div>
</div>
</div>
);
};
export default Drawer;

View File

@ -1,15 +1,15 @@
import { ReactNode } from "react";
interface CalloutProps {
children: ReactNode;
children: ReactNode;
}
const Callout = ({ children }: CalloutProps) => {
return (
<div className="p-4 mb-4 flex items-center bg-purple-50 rounded-sm">
<span className="text-sm text-gray-800">{children}</span>
</div>
);
return (
<div className="p-4 mb-4 flex items-center bg-purple-50 rounded-sm">
<span className="text-sm text-gray-800">{children}</span>
</div>
);
};
export default Callout;
export default Callout;

View File

@ -1,20 +1,17 @@
import React, { ReactNode } from "react";
interface TagProps {
text: string;
icon: React.ReactNode;
text: string;
icon: React.ReactNode;
}
const Card: React.FC<TagProps> = ({ text, icon }) => {
return (
<div className="flex flex-row space-x-2 items-start justify-start border border-gray-200 bg-white hover:bg-gray-50 shadow rounded-md p-4">
<span className="h-5 text-purple-700 w-5">
{icon}
</span>
<span className="text-sm text-gray-800 font-semibold">{text}</span>
</div>
);
return (
<div className="flex flex-row space-x-2 items-start justify-start border border-gray-200 bg-white hover:bg-gray-50 shadow rounded-md p-4">
<span className="h-5 text-purple-700 w-5">{icon}</span>
<span className="text-sm text-gray-800 font-semibold">{text}</span>
</div>
);
};
export default Card;
export default Card;

View File

@ -1,48 +1,74 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/solid"
import React, { useState } from 'react';
import Image from 'next/image';
import {
ChevronDownIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from "@heroicons/react/24/solid";
import React, { useState } from "react";
import Image from "next/image";
import { FilterBox } from "../FilterBox";
export const LandingSearchBar: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState("");
const [showFilterBox, setShowFilterBox] = useState(false);
const toggleFilterBox = () => setShowFilterBox((prev) => !prev);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
};
const clearSearch = () => {
setSearchTerm('');
};
const clearSearch = () => {
setSearchTerm("");
};
return (
<div className="max-w mx-auto">
{/* searchbar */}
<div className="flex items-center bg-white border border-gray-200 shadow rounded-md">
<div className="flex-grow">
<input
className="sm:text-sm text-gray-800 w-full px-6 py-3 rounded-md focus:outline-none"
type="text"
placeholder="Search..."
value={searchTerm}
onChange={handleSearchChange}
/>
return (
<div className="max-w mx-auto">
{/* searchbar */}
<div className="flex items-center bg-white border border-gray-200 shadow rounded-md">
<div className="flex-grow">
<input
className="sm:text-sm text-gray-800 w-full px-6 py-3 rounded-md focus:outline-none"
type="text"
placeholder="Search..."
value={searchTerm}
onChange={handleSearchChange}
/>
</div>
{/* input */}
{searchTerm && (
<button onClick={clearSearch}>
<XMarkIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</button>
)}
<div className="flex flex-row space-x-1 p-3">
<span>
<ChevronDownIcon
className="h-5 w-5 text-gray-500"
onClick={toggleFilterBox}
/>
</span>
{showFilterBox && <FilterBox className="relative top-50" />}
<MagnifyingGlassIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</div>
</div>
{/* search results, for now since it's empty this is the default screen */}
<div className="flex flex-col pt-16 space-y-2 justify-center items-center">
<Image
alt="Landing illustration"
src="/landing_illustration.png"
width={250}
height={250}
/>
<h2 className="font-medium text-medium text-gray-800">
Need to find something? Use the links or the search bar
above to get your results.
</h2>
</div>
</div>
{/* input */}
{searchTerm && (
<button
onClick={clearSearch}
>
<XMarkIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</button>
)}
<div className="p-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
{/* search results, for now since it's empty this is the default screen */}
<div className="flex flex-col pt-16 space-y-2 justify-center items-center">
<Image alt="Landing illustration" src="/landing_illustration.png" width={250} height={250} />
<h2 className="font-medium text-medium text-gray-800">Need to find something? Use the links or the search bar above to get your results.</h2>
</div>
</div>
);
);
};

View File

@ -1,46 +0,0 @@
import React from 'react';
import { HomeIcon, ChevronDoubleLeftIcon, BookmarkIcon, ClipboardIcon, BookOpenIcon } from '@heroicons/react/24/solid';
import { SidebarItem } from './SidebarItem';
import { UserProfile } from './UserProfile';
interface SidebarProps {
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const Sidebar: React.FC<SidebarProps> = ({ setIsSidebarOpen }) => {
return (
<div className="w-64 h-full border border-gray-200 bg-gray-50 px-4">
{/* button to close sidebar */}
<div className="flex justify-end">
<button
onClick={() => setIsSidebarOpen(false)}
className="py-2 text-gray-500 hover:text-gray-800"
aria-label="Close sidebar"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col space-y-8">
{/* user + logout button */}
<div className="flex items-center p-4 space-x-2 border border-gray-200 rounded-md ">
<UserProfile />
</div>
{/* navigation menu */}
<div className="flex flex-col space-y-2">
<h4 className="text-xs font-semibold text-gray-500">Pages</h4>
<nav className="flex flex-col">
<SidebarItem icon={<HomeIcon />} text="Home" />
<SidebarItem icon={<BookmarkIcon />} text="Resources" />
<SidebarItem icon={<ClipboardIcon />} text="Services" />
<SidebarItem icon={<BookOpenIcon />} text="Training Manuals" />
</nav>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@ -1,16 +0,0 @@
interface SidebarItemProps {
icon: React.ReactElement;
text: string;
}
export const SidebarItem: React.FC<SidebarItemProps> = ({ icon, text }) => {
return (
<a href="#" className="flex items-center p-2 space-x-2 hover:bg-gray-200 rounded-md">
<span className="h-5 text-gray-500 w-5">
{icon}
</span>
<span className="flex-grow font-medium text-xs text-gray-500">{text}</span>
</a>
);
};

Some files were not shown because too many files have changed in this diff Show More