Compare commits

...

12 Commits

Author SHA1 Message Date
Andy Chan
2085e45b3e
Merge 51c40684fa into 55a03ff3fd 2025-01-08 17:59:45 -05:00
Prajwal Moharana
55a03ff3fd
Connect update to backend (#55) 2025-01-07 12:13:57 -05:00
Prajwal Moharana
0daf80d222
Prevent employee/volunteer from editting and revamp loading spinner (#54) 2025-01-05 00:25:18 -05:00
Prajwal Moharana
f6b0838c99
Api create moharana (#53)
* Add create user endpoint and update model/entity

* Add next route for adding user and add functionality for user table

* Connect create item to backend and add associated frontend routes
2025-01-04 23:28:12 -05:00
Prajwal Moharana
dff05af79c
Add single tag selection and editting row (#52) 2025-01-04 15:10:07 -05:00
Prajwal Moharana
251222167d
New btn moharana (#51)
* Implement 'Create New' button and fix no tags bug

* Implement local state editting when creating new element

* Add defaults when no tags

* Reset tags whenever new item is created
2025-01-04 13:34:26 -05:00
Prajwal Moharana
a516c414f6
Drawer update moharana (#50)
* Fix div td error in console

* Update drawer + tag dropdown for proper functionality
2025-01-03 15:21:04 -05:00
Prajwal Moharana
fbde92a524
Change npm version to match node 18 (#49) 2025-01-03 15:20:46 -05:00
Prajwal Moharana
00ba6d7df1
Smooth redirection moharana (#48)
* Fix indefinite loading spinner when navigating to same page

* Add middleware to handle navigation depending on authentication
2024-12-17 21:02:40 -05:00
Prajwal Moharana
fdbf4ffa40
Frontend loading indicator foster (#47)
* initial layout component but in sidebar only

* loading for sign out

* Add loading functionality for changing pages

---------

Co-authored-by: emmalynf <efoster@unc.edu>
2024-12-15 22:48:15 -05:00
Andy Chan
51c40684fa
Merge branch 'main' into banish-ds_store 2024-10-22 00:32:17 -04:00
Andy Chan (12beesinatrenchcoat)
65c6da5b96 Banish .DS_Store
Extremely minor annoyance; it's just a macOS file tracking a user's folder view settings
2024-04-02 11:40:41 -04:00
49 changed files with 1923 additions and 839 deletions

View File

@ -44,7 +44,7 @@ RUN mkdir -p /etc/apt/keyrings \
&& 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 \ && 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 update \
&& apt-get install nodejs -y \ && apt-get install nodejs -y \
&& npm install -g npm@latest \ && npm install -g npm@10.8.2 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user # Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user

3
.gitignore vendored
View File

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

View File

@ -19,7 +19,10 @@ openapi_tags = {
# TODO: Create custom exceptions # TODO: Create custom exceptions
@api.post("", response_model=Resource, tags=["Resource"]) @api.post("", response_model=Resource, tags=["Resource"])
def create( def create(
uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() uuid: str,
resource: Resource,
user_svc: UserService = Depends(),
resource_svc: ResourceService = Depends(),
): ):
subject = user_svc.get_user_by_uuid(uuid) subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.create(subject, resource) return resource_svc.create(subject, resource)
@ -27,14 +30,20 @@ def create(
@api.get("", response_model=List[Resource], tags=["Resource"]) @api.get("", response_model=List[Resource], tags=["Resource"])
def get_all( def get_all(
uuid: str, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() uuid: str,
user_svc: UserService = Depends(),
resource_svc: ResourceService = Depends(),
): ):
subject = user_svc.get_user_by_uuid(uuid) subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.get_resource_by_user(subject) return resource_svc.get_resource_by_user(subject)
@api.get("/{name}", response_model=Resource, tags=["Resource"]) @api.get("/{name}", response_model=Resource, tags=["Resource"])
def get_by_name( def get_by_name(
name:str, uuid:str, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() name: str,
uuid: str,
user_svc: UserService = Depends(),
resource_svc: ResourceService = Depends(),
): ):
subject = user_svc.get_user_by_uuid(uuid) subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.get_resource_by_name(name, subject) return resource_svc.get_resource_by_name(name, subject)
@ -42,7 +51,10 @@ def get_by_name(
@api.put("", response_model=Resource, tags=["Resource"]) @api.put("", response_model=Resource, tags=["Resource"])
def update( def update(
uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() uuid: str,
resource: Resource,
user_svc: UserService = Depends(),
resource_svc: ResourceService = Depends(),
): ):
subject = user_svc.get_user_by_uuid(uuid) subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.update(subject, resource) return resource_svc.update(subject, resource)
@ -50,7 +62,10 @@ def update(
@api.delete("", response_model=None, tags=["Resource"]) @api.delete("", response_model=None, tags=["Resource"])
def delete( def delete(
uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() uuid: str,
resource: Resource,
user_svc: UserService = Depends(),
resource_svc: ResourceService = Depends(),
): ):
subject = user_svc.get_user_by_uuid(uuid) subject = user_svc.get_user_by_uuid(uuid)
resource_svc.delete(subject, resource) resource_svc.delete(subject, resource)

View File

@ -16,8 +16,8 @@ openapi_tags = {
# TODO: Enable authorization by passing user uuid to API # TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions # TODO: Create custom exceptions
@api.get("/all", response_model=List[User], tags=["Users"]) @api.get("/all", response_model=List[User], tags=["Users"])
def get_all(user_id: str, user_svc: UserService = Depends()): def get_all(uuid: str, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(user_id) subject = user_svc.get_user_by_uuid(uuid)
if subject.role != UserTypeEnum.ADMIN: if subject.role != UserTypeEnum.ADMIN:
raise Exception(f"Insufficient permissions for user {subject.uuid}") raise Exception(f"Insufficient permissions for user {subject.uuid}")
@ -25,6 +25,24 @@ def get_all(user_id: str, user_svc: UserService = Depends()):
return user_svc.all() return user_svc.all()
@api.get("/{user_id}", response_model=User, tags=["Users"]) @api.get("/{uuid}", response_model=User, tags=["Users"])
def get_by_uuid(user_id: str, user_svc: UserService = Depends()): def get_by_uuid(uuid: str, user_svc: UserService = Depends()):
return user_svc.get_user_by_uuid(user_id) return user_svc.get_user_by_uuid(uuid)
@api.post("/", response_model=User, tags=["Users"])
def create_user(uuid: str, user: User, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(uuid)
if subject.role != UserTypeEnum.ADMIN:
raise Exception(f"Insufficient permissions for user {subject.uuid}")
return user_svc.create(user)
@api.put("/", response_model=User, tags=["Users"])
def update_user(uuid: str, user: User, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(uuid)
if subject.role != UserTypeEnum.ADMIN:
raise Exception(f"Insufficient permissions for user {subject.uuid}")
return user_svc.update(user)

View File

@ -38,8 +38,8 @@ class UserEntity(EntityBase):
program: Mapped[list[ProgramTypeEnum]] = mapped_column( program: Mapped[list[ProgramTypeEnum]] = mapped_column(
ARRAY(Enum(ProgramTypeEnum)), nullable=False ARRAY(Enum(ProgramTypeEnum)), nullable=False
) )
experience: Mapped[int] = mapped_column(Integer, nullable=False) experience: Mapped[int] = mapped_column(Integer, nullable=True)
group: Mapped[str] = mapped_column(String(50)) group: Mapped[str] = mapped_column(String(50), nullable=True)
uuid: Mapped[str] = mapped_column(String, nullable=True) uuid: Mapped[str] = mapped_column(String, nullable=True)
@classmethod @classmethod

View File

@ -10,9 +10,9 @@ class User(BaseModel):
id: int | None = None id: int | None = None
username: str = Field(..., description="The username of the user") username: str = Field(..., description="The username of the user")
email: str = Field(..., description="The e-mail of the user") email: str = Field(..., description="The e-mail of the user")
experience: int = Field(..., description="Years of Experience of the User") experience: int | None = Field(None, description="Years of Experience of the User")
group: str group: str | None = Field(None, description="The group of the user")
program: List[ProgramTypeEnum] program: List[ProgramTypeEnum] | None = None
role: UserTypeEnum role: UserTypeEnum | None = None
created_at: Optional[datetime] = datetime.now() created_at: Optional[datetime] = datetime.now()
uuid: str | None = None uuid: str | None = None

View File

@ -17,7 +17,7 @@ class ResourceService:
def get_resource_by_user(self, subject: User) -> list[Resource]: def get_resource_by_user(self, subject: User) -> list[Resource]:
"""Resource method getting all of the resources that a user has access to based on role""" """Resource method getting all of the resources that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER: if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ResourceEntity) query = select(ResourceEntity).order_by(ResourceEntity.id)
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
return [resource.to_model() for resource in entities] return [resource.to_model() for resource in entities]
else: else:
@ -86,10 +86,10 @@ class ResourceService:
raise ResourceNotFoundException( raise ResourceNotFoundException(
f"No resource found with matching id: {resource.id}" f"No resource found with matching id: {resource.id}"
) )
entity.name = resource.name entity.name = resource.name if resource.name else entity.name
entity.summary = resource.summary entity.summary = resource.summary if resource.summary else entity.summary
entity.link = resource.link entity.link = resource.link if resource.link else entity.link
entity.program = resource.program entity.program = resource.program if resource.program else entity.program
self._session.commit() self._session.commit()
return entity.to_model() return entity.to_model()

View File

@ -22,14 +22,18 @@ class ServiceService:
def get_service_by_user(self, subject: User) -> list[Service]: def get_service_by_user(self, subject: User) -> list[Service]:
"""Resource method getting all of the resources that a user has access to based on role""" """Resource method getting all of the resources that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER: if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ServiceEntity) query = select(ServiceEntity).order_by(ServiceEntity.id)
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
return [service.to_model() for service in entities] return [service.to_model() for service in entities]
else: else:
programs = subject.program programs = subject.program
services = [] services = []
for program in programs: for program in programs:
entities = self._session.query(ServiceEntity).where(ServiceEntity.program == program).all() entities = (
self._session.query(ServiceEntity)
.where(ServiceEntity.program == program)
.all()
)
for entity in entities: for entity in entities:
services.append(entity.to_model()) services.append(entity.to_model())
return [service for service in services] return [service for service in services]
@ -37,14 +41,14 @@ class ServiceService:
def get_service_by_name(self, name: str, subject: User) -> Service: def get_service_by_name(self, name: str, subject: User) -> Service:
"""Service method getting services by id.""" """Service method getting services by id."""
query = select(ServiceEntity).where( query = select(ServiceEntity).where(
and_( and_(ServiceEntity.name == name, ServiceEntity.program.in_(subject.program))
ServiceEntity.name == name, ServiceEntity.program.in_(subject.program)
)
) )
entity = self._session.scalars(query).one_or_none() entity = self._session.scalars(query).one_or_none()
if entity is None: if entity is None:
raise ServiceNotFoundException(f"Service with name: {name} does not exist or program has not been assigned") raise ServiceNotFoundException(
f"Service with name: {name} does not exist or program has not been assigned"
)
return entity.to_model() return entity.to_model()
@ -66,7 +70,7 @@ class ServiceService:
raise ProgramNotAssignedException( raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot update service" f"User is not {UserTypeEnum.ADMIN}, cannot update service"
) )
query = select(ServiceEntity).where(ServiceEntity.id == service.id) query = select(ServiceEntity).where(ServiceEntity.id == service.id)
entity = self._session.scalars(query).one_or_none() entity = self._session.scalars(query).one_or_none()
@ -75,11 +79,13 @@ class ServiceService:
"The service you are searching for does not exist." "The service you are searching for does not exist."
) )
entity.name = service.name entity.name = service.name if service.name else entity.name
entity.status = service.status entity.status = service.status if service.status else entity.status
entity.summary = service.summary entity.summary = service.summary if service.summary else entity.summary
entity.requirements = service.requirements entity.requirements = (
entity.program = service.program service.requirements if service.requirements else entity.requirements
)
entity.program = service.program if service.program else entity.program
self._session.commit() self._session.commit()
return entity.to_model() return entity.to_model()
@ -88,7 +94,7 @@ class ServiceService:
"""Deletes a service from the table.""" """Deletes a service from the table."""
if subject.role != UserTypeEnum.ADMIN: if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}") raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}")
query = select(ServiceEntity).where(ServiceEntity.id == service.id) query = select(ServiceEntity).where(ServiceEntity.id == service.id)
entity = self._session.scalars(query).one_or_none() entity = self._session.scalars(query).one_or_none()

View File

@ -43,10 +43,10 @@ class UserService:
def all(self) -> list[User]: def all(self) -> list[User]:
""" """
Returns a list of all Users Returns a list of all Users ordered by id
""" """
query = select(UserEntity) query = select(UserEntity).order_by(UserEntity.id)
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities] return [entity.to_model() for entity in entities]
@ -61,22 +61,21 @@ class UserService:
""" """
try: try:
if (user.id != None): if user.id != None:
user = self.get_user_by_id(user.id) user = self.get_user_by_id(user.id)
else:
user_entity = UserEntity.from_model(user)
# add new user to table
self._session.add(user_entity)
self._session.commit()
except: except:
# if does not exist, create new object raise Exception(f"Failed to create user")
user_entity = UserEntity.from_model(user)
# add new user to table return user
self._session.add(user_entity)
self._session.commit() def delete(self, user: User) -> None:
finally:
# return added object
return user
def delete(self, user: User) -> None:
""" """
Delete a user Delete a user
Args: the user to delete Args: the user to delete
@ -86,34 +85,30 @@ class UserService:
if obj is None: if obj is None:
raise Exception(f"No matching user found") raise Exception(f"No matching user found")
self._session.delete(obj) self._session.delete(obj)
self._session.commit() self._session.commit()
def update(self, user: User) -> User:
def update(self, user: User) -> User:
""" """
Updates a user Updates a user
Args: User to be updated Args: User to be updated
Returns: The updated User Returns: The updated User
""" """
obj = self._session.get(UserEntity, user.id) obj = self._session.get(UserEntity, user.id)
if obj is None: if obj is None:
raise Exception(f"No matching user found") raise Exception(f"No matching user found")
obj.username = user.username obj.username = user.username if user.username else obj.username
obj.role = user.role obj.role = user.role if user.role else obj.role
obj.email = user.email obj.email = user.email if user.email else obj.email
obj.program = user.program obj.program = user.program if user.program else obj.program
obj.experience = user.experience obj.experience = user.experience if user.experience else obj.experience
obj.group = user.group obj.group = user.group if user.group else obj.group
self._session.commit() self._session.commit()
return obj.to_model() return obj.to_model()

View File

@ -24,8 +24,8 @@ This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-opti
To learn more about Next.js, take a look at the following resources: 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. - [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. - [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! You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

View File

@ -4,41 +4,98 @@ import { PageLayout } from "@/components/PageLayout";
import UserTable from "@/components/Table/UserTable"; import UserTable from "@/components/Table/UserTable";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { UsersIcon } from "@heroicons/react/24/solid"; import { UsersIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function Page() { export default function Page() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [currUser, setCurrUser] = useState<User | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function getUser() { async function getUsers() {
const supabase = createClient(); try {
setIsLoading(true);
setError(null);
const { data, error } = await supabase.auth.getUser(); const supabase = createClient();
const { data: userData, error: authError } =
await supabase.auth.getUser();
if (error) { if (authError) {
console.log("Accessed admin page but not logged in"); throw new Error("Authentication failed. Please sign in.");
return; }
// Fetch users list and current user data in parallel
const [usersResponse, userResponse] = await Promise.all([
fetch(`/api/user/all?uuid=${userData.user.id}`),
fetch(`/api/user?uuid=${userData.user.id}`),
]);
// Check for HTTP errors
if (!usersResponse.ok) {
throw new Error(
`Failed to fetch users: ${usersResponse.statusText}`
);
}
if (!userResponse.ok) {
throw new Error(
`Failed to fetch user data: ${userResponse.statusText}`
);
}
// Parse the responses
const [usersAPI, currUserData] = await Promise.all([
usersResponse.json(),
userResponse.json(),
]);
// Verify admin status
if (currUserData.role !== "ADMIN") {
throw new Error("Unauthorized: Admin access required");
}
setUsers(usersAPI);
setCurrUser(currUserData);
} catch (err) {
console.error("Error fetching data:", err);
setError(
err instanceof Error
? err.message
: "An unexpected error occurred"
);
setUsers([]);
setCurrUser(undefined);
} finally {
setIsLoading(false);
} }
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(); getUsers();
}, []); }, []);
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}> <PageLayout title="Users" icon={<UsersIcon />}>
<UserTable data={users} setData={setUsers} /> {isLoading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-purple-700" />
</div>
) : error ? (
<div className="flex justify-center items-center h-64">
<div className="text-red-500 text-center">
<p className="text-lg font-semibold">Error</p>
<p className="text-sm">{error}</p>
</div>
</div>
) : (
<UserTable
data={users}
setData={setUsers}
user={currUser}
/>
)}
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import Resource from "@/utils/models/Resource";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/resource`;
try {
const resourceData = await request.json();
console.log("RESOURCE DATA", JSON.stringify(resourceData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(resourceData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const createdResource: Resource = await response.json();
return NextResponse.json(createdResource, { status: response.status });
} catch (error) {
console.error("Error creating resource:", error);
return NextResponse.json(
{ error: "Failed to create resource" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,37 @@
import Resource from "@/utils/models/Resource";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/resource`;
try {
const resourceData = await request.json();
console.log("RESOURCE DATA", JSON.stringify(resourceData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(resourceData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const resource: Resource = await response.json();
return NextResponse.json(resource, { status: response.status });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to update resource" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import User from "@/utils/models/User";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/service`;
try {
const serviceData = await request.json();
console.log("SERVICE DATA", JSON.stringify(serviceData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(serviceData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const createdService: User = await response.json();
return NextResponse.json(createdService, { status: response.status });
} catch (error) {
console.error("Error creating service:", error);
return NextResponse.json(
{ error: "Failed to create service" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import Service from "@/utils/models/Service";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/service`;
try {
const serviceData = await request.json();
console.log("SERVICE DATA", JSON.stringify(serviceData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(serviceData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const createdService: Service = await response.json();
return NextResponse.json(createdService, { status: response.status });
} catch (error) {
console.error("Error creating service:", error);
return NextResponse.json(
{ error: "Failed to update service" },
{ status: 500 }
);
}
}

View File

@ -9,7 +9,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid"); const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`); const data = await fetch(`${apiEndpoint}?uuid=${uuid}`);
const userData: User[] = await data.json(); const userData: User[] = await data.json();
// TODO: Remove make every user visible // TODO: Remove make every user visible

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import User from "@/utils/models/User";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user`;
try {
const userData = await request.json();
console.log("USER DATA", JSON.stringify(userData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const createdUser: User = await response.json();
return NextResponse.json(createdUser, { status: response.status });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import User from "@/utils/models/User";
export async function POST(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user`;
try {
const userData = await request.json();
console.log("USER DATA", JSON.stringify(userData));
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
// Send the POST request to the backend
const response = await fetch(`${apiEndpoint}?uuid=${uuid}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const createdUser: User = await response.json();
return NextResponse.json(createdUser, { status: response.status });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}

View File

@ -77,6 +77,8 @@ export default function Page() {
alt="Compass Center logo." alt="Compass Center logo."
width={100} width={100}
height={91} height={91}
style={{ height: "auto", width: "auto" }}
priority
/> />
<h1 className="font-bold text-2xl text-purple-800">Login</h1> <h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6"> <div className="mb-6">

View File

@ -4,31 +4,68 @@ import { PageLayout } from "@/components/PageLayout";
import Resource from "@/utils/models/Resource"; import Resource from "@/utils/models/Resource";
import ResourceTable from "@/components/Table/ResourceTable"; import ResourceTable from "@/components/Table/ResourceTable";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { BookmarkIcon } from "@heroicons/react/24/solid"; import { BookmarkIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import User from "@/utils/models/User";
export default function Page() { export default function Page() {
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [currUser, setCurrUser] = useState<User | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function getResources() { async function getResources() {
const supabase = createClient(); try {
setIsLoading(true);
setError(null);
const { data, error } = await supabase.auth.getUser(); const supabase = createClient();
const { data: userData, error: authError } =
await supabase.auth.getUser();
if (error) { if (authError) {
console.log("Accessed admin page but not logged in"); throw new Error("Authentication failed. Please sign in.");
return; }
// Fetch resources and user data in parallel
const [resourceResponse, userResponse] = await Promise.all([
fetch(`/api/resource/all?uuid=${userData.user.id}`),
fetch(`/api/user?uuid=${userData.user.id}`),
]);
// Check for HTTP errors
if (!resourceResponse.ok) {
throw new Error(
`Failed to fetch resources: ${resourceResponse.statusText}`
);
}
if (!userResponse.ok) {
throw new Error(
`Failed to fetch user data: ${userResponse.statusText}`
);
}
// Parse the responses
const [resourcesAPI, currUserData] = await Promise.all([
resourceResponse.json(),
userResponse.json(),
]);
setResources(resourcesAPI);
setCurrUser(currUserData);
} catch (err) {
console.error("Error fetching data:", err);
setError(
err instanceof Error
? err.message
: "An unexpected error occurred"
);
setResources([]);
setCurrUser(undefined);
} finally {
setIsLoading(false);
} }
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(); getResources();
@ -36,9 +73,25 @@ export default function Page() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Resources" icon={<BookmarkIcon />}> <PageLayout title="Resources" icon={<BookmarkIcon />}>
<ResourceTable data={resources} setData={setResources} /> {isLoading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-purple-700" />
</div>
) : error ? (
<div className="flex justify-center items-center h-64">
<div className="text-red-500 text-center">
<p className="text-lg font-semibold">Error</p>
<p className="text-sm">{error}</p>
</div>
</div>
) : (
<ResourceTable
data={resources}
setData={setResources}
user={currUser}
/>
)}
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -3,31 +3,69 @@
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import ServiceTable from "@/components/Table/ServiceTable"; import ServiceTable from "@/components/Table/ServiceTable";
import Service from "@/utils/models/Service"; import Service from "@/utils/models/Service";
import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { ClipboardIcon } from "@heroicons/react/24/solid"; import { ClipboardIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function Page() { export default function Page() {
const [services, setServices] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
const [currUser, setCurrUser] = useState<User | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function getServices() { async function getServices() {
const supabase = createClient(); try {
setIsLoading(true);
setError(null);
const { data, error } = await supabase.auth.getUser(); const supabase = createClient();
const { data: userData, error: authError } =
await supabase.auth.getUser();
if (error) { if (authError) {
console.log("Accessed admin page but not logged in"); throw new Error("Authentication failed. Please sign in.");
return; }
// Fetch services and user data in parallel
const [serviceResponse, userResponse] = await Promise.all([
fetch(`/api/service/all?uuid=${userData.user.id}`),
fetch(`/api/user?uuid=${userData.user.id}`),
]);
// Check for HTTP errors
if (!serviceResponse.ok) {
throw new Error(
`Failed to fetch services: ${serviceResponse.statusText}`
);
}
if (!userResponse.ok) {
throw new Error(
`Failed to fetch user data: ${userResponse.statusText}`
);
}
// Parse the responses
const [servicesAPI, currUserData] = await Promise.all([
serviceResponse.json(),
userResponse.json(),
]);
setCurrUser(currUserData);
setServices(servicesAPI);
} catch (err) {
console.error("Error fetching data:", err);
setError(
err instanceof Error
? err.message
: "An unexpected error occurred"
);
setServices([]);
setCurrUser(undefined);
} finally {
setIsLoading(false);
} }
const serviceListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/service/all?uuid=${data.user.id}`
);
const servicesAPI: Service[] = await serviceListData.json();
setServices(servicesAPI);
} }
getServices(); getServices();
@ -35,9 +73,25 @@ export default function Page() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Services" icon={<ClipboardIcon />}> <PageLayout title="Services" icon={<ClipboardIcon />}>
<ServiceTable data={services} setData={setServices} /> {isLoading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-purple-700" />
</div>
) : error ? (
<div className="flex justify-center items-center h-64">
<div className="text-red-500 text-center">
<p className="text-lg font-semibold">Error</p>
<p className="text-sm">{error}</p>
</div>
</div>
) : (
<ServiceTable
data={services}
setData={setServices}
user={currUser}
/>
)}
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -76,6 +76,8 @@ export default function Page() {
alt="Compass Center logo." alt="Compass Center logo."
width={100} width={100}
height={91} height={91}
style={{ height: "auto", width: "auto" }}
priority
/> />
<h1 className="font-bold text-2xl text-purple-800">Login</h1> <h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6"> <div className="mb-6">

View File

@ -0,0 +1,232 @@
import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react";
import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index";
import { Details } from "./Drawer";
type CreateDrawerProps = {
details: Details[];
onCreate: (newItem: any) => boolean;
};
const CreateDrawer: FunctionComponent<CreateDrawerProps> = ({
details,
onCreate,
}: CreateDrawerProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false);
const [newItemContent, setNewItemContent] = useState<any>({});
const [renderKey, setRenderKey] = useState(0);
const handleContentChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setNewItemContent((prev: any) => ({
...prev,
[name]: value,
}));
};
const initializeSelectField = (key: string) => {
if (!newItemContent[key]) {
setNewItemContent((prev: any) => ({
...prev,
[key]: [],
}));
}
};
const handleCreate = () => {
if (onCreate(newItemContent)) {
console.log("newItemContent", newItemContent);
setNewItemContent({});
setIsOpen(false);
}
};
const toggleDrawer = () => {
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
if (!isOpen) {
setRenderKey((prev) => prev + 1);
}
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
const drawerClassName = `fixed top-0 right-0 h-full bg-white transform ease-in-out duration-300 z-20 overflow-y-auto ${
isOpen ? "translate-x-0 shadow-2xl" : "translate-x-full"
} ${isFull ? "w-full" : "w-[600px]"}`;
const iconComponent = isFull ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
);
return (
<div>
<button
className="text-sm text-white font-medium bg-purple-600 hover:bg-purple-700 px-3 py-1 rounded-md"
onClick={toggleDrawer}
>
Create New
</button>
<div className={drawerClassName}>
<div className="sticky top-0 flex items-center justify-between p-4 bg-white border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">
Create New Item
</h2>
<div className="flex items-center space-x-2">
<button
onClick={toggleDrawerFullScreen}
className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
{iconComponent}
</button>
<button
onClick={toggleDrawer}
className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-6">
<div className="flex flex-col space-y-4">
{details.map((detail, index) => {
const value = newItemContent[detail.key] || "";
let inputField;
switch (detail.inputType) {
case "select-one":
initializeSelectField(detail.key);
inputField = (
<TagsInput
key={`${detail.key}-${renderKey}`}
presetValue={[]}
presetOptions={
detail.presetOptionsValues || []
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
singleValue={true}
onTagsChange={(
tags: Set<string>
) => {
setNewItemContent(
(prev: any) => ({
...prev,
[detail.key]:
tags.size > 0
? Array.from(
tags
)[0]
: null,
})
);
}}
/>
);
break;
case "select-multiple":
initializeSelectField(detail.key);
inputField = (
<TagsInput
key={`${detail.key}-${renderKey}`}
presetValue={
newItemContent[detail.key] || []
}
presetOptions={
detail.presetOptionsValues || []
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
onTagsChange={(
tags: Set<string>
) => {
setNewItemContent(
(prev: any) => ({
...prev,
[detail.key]:
Array.from(tags),
})
);
}}
/>
);
break;
case "textarea":
inputField = (
<textarea
name={detail.key}
value={value}
onChange={handleContentChange}
rows={4}
onInput={(e) => {
const target =
e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height =
target.scrollHeight + "px";
}}
className="w-full p-2 focus:outline-none border border-gray-200 rounded-md resize-none font-normal"
placeholder={`Enter ${detail.label.toLowerCase()}...`}
/>
);
break;
default:
inputField = (
<input
type={detail.inputType}
name={detail.key}
value={value}
onChange={handleContentChange}
className="w-full p-2 border border-gray-200 rounded-md focus:outline-none focus:border-purple-500"
placeholder={`Enter ${detail.label.toLowerCase()}...`}
/>
);
}
return (
<div
key={index}
className="flex flex-col gap-2"
>
<label className="flex items-center text-sm text-gray-700 gap-2">
<span className="text-gray-500">
{detail.icon}
</span>
{detail.label}
</label>
{inputField}
</div>
);
})}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700"
>
Create
</button>
</div>
</div>
</div>
</div>
);
};
export default CreateDrawer;

View File

@ -1,78 +1,109 @@
import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react"; import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react";
import React, { useState } from "react"; import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid"; import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import { import { StarIcon as SolidStarIcon, UserIcon } from "@heroicons/react/24/solid";
StarIcon as SolidStarIcon,
EnvelopeIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import { import {
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
ArrowsPointingInIcon, ArrowsPointingInIcon,
StarIcon as OutlineStarIcon, StarIcon as OutlineStarIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index"; import TagsInput from "../TagsInput/Index";
import { Tag } from "../TagsInput/Tag";
type DrawerProps = { type InputType =
title: string; | "text"
children: ReactNode; | "email"
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; | "textarea"
type?: "button" | "submit" | "reset"; // specify possible values for type | "select-one"
disabled?: boolean; | "select-multiple";
editableContent?: any;
onSave?: (content: any) => void;
rowContent?: any;
setData: Dispatch<SetStateAction<any>>;
};
interface EditContent { export interface Details {
content: string; key: string;
isEditing: boolean; label: string;
inputType: InputType;
icon: ReactNode;
presetOptionsValues?: string[];
presetOptionsSetter?: Dispatch<SetStateAction<string[]>>;
} }
type DrawerProps = {
titleKey: string;
details: Details[];
rowContent?: any;
setRowContent?: Dispatch<SetStateAction<any>>;
isAdmin?: boolean;
updateRoute: string;
};
const Drawer: FunctionComponent<DrawerProps> = ({ const Drawer: FunctionComponent<DrawerProps> = ({
title, titleKey,
children, details,
onSave,
editableContent,
rowContent, rowContent,
setData, setRowContent,
}) => { isAdmin,
updateRoute,
}: DrawerProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false); const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent); const [tempRowContent, setTempRowContent] = useState(rowContent);
const onRowUpdate = (updatedRow: any) => { const handleTempRowContentChangeHTML = (
setData((prevData: any) => e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
prevData.map((row: any) => ) => {
row.id === updatedRow.id ? updatedRow : row const { name, value } = e.target;
) handleTempRowContentChange(name, value);
);
}; };
const handleTempRowContentChange = (e) => { const handleTempRowContentChange = (name: string, value: any) => {
const { name, value } = e.target; setTempRowContent((prev: any) => ({
console.log(name); ...prev,
console.log(value);
setTempRowContent((prevContent) => ({
...prevContent,
[name]: value, [name]: value,
})); }));
}; };
const handleEnterPress = (e) => { const handleEnterPress = (
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault();
// Update the rowContent with the temporaryRowContent
if (onRowUpdate) {
onRowUpdate(tempRowContent);
}
} }
}; };
const handleUpdate = async () => {
const response = await fetch(updateRoute, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(tempRowContent),
});
return response;
};
const toggleDrawer = () => { const toggleDrawer = () => {
if (setRowContent && isOpen) {
// Check if any values have changed
const hasChanges = Object.keys(tempRowContent).some(
(key) => tempRowContent[key] !== rowContent[key]
);
if (hasChanges) {
handleUpdate().then((response) => {
if (response.ok) {
setRowContent((prev: any) => {
return prev.map((row: any) => {
if (row.id === tempRowContent.id) {
return tempRowContent;
}
return row;
});
});
}
});
}
}
setIsOpen(!isOpen); setIsOpen(!isOpen);
if (isFull) { if (isFull) {
setIsFull(!isFull); setIsFull(!isFull);
@ -99,34 +130,6 @@ const Drawer: FunctionComponent<DrawerProps> = ({
<OutlineStarIcon 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 ( return (
<div> <div>
<button <button
@ -137,7 +140,6 @@ const Drawer: FunctionComponent<DrawerProps> = ({
> >
Open Open
</button> </button>
<div className={drawerClassName}></div>
<div className={drawerClassName}> <div className={drawerClassName}>
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex flex-row items-center justify-between space-x-2"> <div className="flex flex-row items-center justify-between space-x-2">
@ -145,7 +147,7 @@ const Drawer: FunctionComponent<DrawerProps> = ({
<UserIcon /> <UserIcon />
</span> </span>
<h2 className="text-lg text-gray-800 font-semibold"> <h2 className="text-lg text-gray-800 font-semibold">
{rowContent.username} {rowContent[titleKey]}
</h2> </h2>
</div> </div>
<div> <div>
@ -170,81 +172,202 @@ const Drawer: FunctionComponent<DrawerProps> = ({
</div> </div>
</div> </div>
<div className="p-4"> <div className="p-4">
<table className="p-4"> <div className="flex flex-col space-y-3">
<tbody className="items-center"> {details.map((detail, index) => {
<tr className="w-full text-xs items-center flex flex-row justify-between"> const value = tempRowContent[detail.key];
<div className="flex flex-row space-x-2 text-gray-500 items-center"> let valueToRender = <></>;
<td>
<UserIcon className="h-4 w-4" /> switch (detail.inputType) {
</td> case "select-one":
<td className="w-32">Username</td> valueToRender = (
<div className="flex-1">
<div className="rounded-md px-2 py-1">
{isAdmin ? (
<TagsInput
presetValue={
typeof value ===
"string"
? [value]
: value || []
}
presetOptions={
detail.presetOptionsValues ||
[]
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
singleValue={true}
onTagsChange={(
tags: Set<string>
) => {
const tagsArray =
Array.from(
tags
);
handleTempRowContentChange(
detail.key,
tagsArray.length >
0
? tagsArray[0]
: null
);
}}
/>
) : (
<div className="flex">
<Tag>
{value
? value
: "no value"}
</Tag>
</div>
)}
</div>
</div>
);
break;
case "select-multiple":
valueToRender = (
<div className="flex-1">
<div className="rounded-md px-2 py-1">
{isAdmin ? (
<TagsInput
presetValue={
typeof value ===
"string"
? [value]
: value || []
}
presetOptions={
detail.presetOptionsValues ||
[]
}
setPresetOptions={
detail.presetOptionsSetter ||
(() => {})
}
onTagsChange={(
tags: Set<string>
) => {
handleTempRowContentChange(
detail.key,
Array.from(tags)
);
}}
/>
) : (
<div className="flex flex-wrap gap-2">
{value &&
value.length > 0 ? (
value.map(
(
tag: string,
index: number
) => (
<Tag
key={
index
}
>
{tag}
</Tag>
)
)
) : (
<Tag>
no requirements
</Tag>
)}
</div>
)}
</div>
</div>
);
break;
case "textarea":
valueToRender = (
<div className="flex-1">
<div className="hover:bg-gray-50 rounded-md px-2 py-1">
<textarea
name={detail.key}
value={value}
onChange={
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
rows={4}
disabled={!isAdmin}
onInput={(e) => {
const target =
e.target as HTMLTextAreaElement;
target.style.height =
"auto";
target.style.height =
target.scrollHeight +
"px";
}}
className="w-full p-2 focus:outline-none border border-gray-200 rounded-md resize-none font-normal bg-transparent"
/>
</div>
</div>
);
break;
case "text":
valueToRender = (
<div className="flex-1">
<div className="hover:bg-gray-50 rounded-md px-2 py-1">
<input
type={detail.inputType}
name={detail.key}
value={value}
disabled={!isAdmin}
onChange={
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
className="w-full p-1 focus:outline-gray-200 bg-transparent"
/>
</div>
</div>
);
break;
case "email":
valueToRender = (
<div className="flex-1">
<div className="hover:bg-gray-50 rounded-md px-2 py-1">
<input
type={detail.inputType}
name={detail.key}
value={value}
onChange={
handleTempRowContentChangeHTML
}
onKeyDown={handleEnterPress}
disabled={!isAdmin}
className="w-full p-1 font-normal hover:text-gray-400 focus:outline-gray-200 underline text-gray-500 bg-transparent"
/>
</div>
</div>
);
break;
}
return (
<div
key={index}
className="flex items-center text-xs gap-3"
>
<div className="flex items-center text-gray-500">
{detail.icon}
</div>
<div className="w-32">{detail.label}</div>
{valueToRender}
</div> </div>
<td className="w-3/4 w-3/4 p-2 pl-0"> );
<input })}
type="text" </div>
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 /> <br />
</div> </div>
</div> </div>

View File

@ -33,7 +33,7 @@ export const FilterBox = () => {
> >
<span>{tag}</span> <span>{tag}</span>
<span <span
className="ml-2 cursor-pointer" className="cursor-pointer"
onClick={() => handleTagChange(tag)} onClick={() => handleTagChange(tag)}
> >
&times; &times;

View File

@ -0,0 +1,67 @@
"use client";
import Sidebar from "@/components/Sidebar/Sidebar";
import React, { useState, useEffect } from "react";
import { createClient } from "@/utils/supabase/client";
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(true);
const [user, setUser] = useState<User | null>(null); // Initialize user as null
const router = useRouter();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error || !data?.user) {
console.log("User not logged in or error fetching user");
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); // Set user data after fetching
}
getUser();
}, [router]);
if (!user) {
return <Loading />; // Show loading screen while the user is being fetched
}
return (
<div className="flex">
{/* Sidebar is shared across all pages */}
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
isSidebarOpen={isSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
{/* Page content */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children} {/* Render page-specific content here */}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import { import {
HomeIcon, HomeIcon,
ChevronDoubleLeftIcon, ChevronDoubleLeftIcon,
@ -26,6 +26,7 @@ const Sidebar: React.FC<SidebarProps> = ({
email, email,
isAdmin: admin, isAdmin: admin,
}) => { }) => {
const [isLoading, setIsLoading] = useState(false);
return ( return (
<> <>
{/* Button to open the sidebar. */} {/* Button to open the sidebar. */}
@ -62,11 +63,24 @@ const Sidebar: React.FC<SidebarProps> = ({
</button> </button>
</div> </div>
<div className="flex flex-col space-y-8"> {/* Loading indicator*/}
{/* user + logout button */} {isLoading && (
<div className="flex items-center p-4 space-x-2 border border-gray-200 rounded-md "> <div className="fixed top-2 left-2">
<UserProfile name={name} email={email} /> <div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-700" />
</div>
</div> </div>
)}
<div className="flex flex-col space-y-8">
<div className="flex items-center p-4 space-x-2 border rounded-md">
<UserProfile
name={name}
email={email}
setLoading={setIsLoading}
/>
</div>
{/* navigation menu */} {/* navigation menu */}
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h4 className="text-xs font-semibold text-gray-500"> <h4 className="text-xs font-semibold text-gray-500">
@ -79,6 +93,7 @@ const Sidebar: React.FC<SidebarProps> = ({
text="Admin" text="Admin"
active={true} active={true}
redirect="/admin" redirect="/admin"
onClick={setIsLoading}
/> />
)} )}
@ -87,24 +102,28 @@ const Sidebar: React.FC<SidebarProps> = ({
text="Home" text="Home"
active={true} active={true}
redirect="/home" redirect="/home"
onClick={setIsLoading}
/> />
<SidebarItem <SidebarItem
icon={<BookmarkIcon />} icon={<BookmarkIcon />}
text="Resources" text="Resources"
active={true} active={true}
redirect="/resource" redirect="/resource"
onClick={setIsLoading}
/> />
<SidebarItem <SidebarItem
icon={<ClipboardIcon />} icon={<ClipboardIcon />}
text="Services" text="Services"
active={true} active={true}
redirect="/service" redirect="/service"
onClick={setIsLoading}
/> />
<SidebarItem <SidebarItem
icon={<BookOpenIcon />} icon={<BookOpenIcon />}
text="Training Manuals" text="Training Manuals"
active={true} active={true}
redirect="/training-manuals" redirect="/training-manuals"
onClick={setIsLoading}
/> />
</nav> </nav>
</div> </div>

View File

@ -1,10 +1,12 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
interface SidebarItemProps { interface SidebarItemProps {
icon: React.ReactElement; icon: React.ReactElement;
text: string; text: string;
active: boolean; active: boolean;
redirect: string; redirect: string;
onClick: React.Dispatch<React.SetStateAction<boolean>>;
} }
export const SidebarItem: React.FC<SidebarItemProps> = ({ export const SidebarItem: React.FC<SidebarItemProps> = ({
@ -12,9 +14,15 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
text, text,
active, active,
redirect, redirect,
onClick,
}) => { }) => {
const pathname = usePathname();
return ( return (
<Link <Link
onClick={() =>
pathname.startsWith(redirect) ? onClick(false) : onClick(true)
}
href={redirect} href={redirect}
className={ className={
active active

View File

@ -1,15 +1,22 @@
import { Bars2Icon } from "@heroicons/react/24/solid"; import {
Bars2Icon,
DocumentTextIcon,
LinkIcon,
ListBulletIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { RowOpenAction } from "@/components/Table/RowOpenAction"; import { RowOpenAction } from "@/components/Table/RowOpenAction";
import Table from "@/components/Table/Table"; import Table from "@/components/Table/Table";
import TagsInput from "@/components/TagsInput/Index";
import Resource from "@/utils/models/Resource"; import Resource from "@/utils/models/Resource";
import { Details } from "../Drawer/Drawer";
import { Tag } from "../TagsInput/Tag";
import User from "@/utils/models/User";
type ResourceTableProps = { type ResourceTableProps = {
data: Resource[]; data: Resource[];
setData: Dispatch<SetStateAction<Resource[]>>; setData: Dispatch<SetStateAction<Resource[]>>;
user?: User;
}; };
/** /**
@ -17,66 +24,126 @@ type ResourceTableProps = {
* @param props.data Stateful list of resources to be displayed by the table * @param props.data Stateful list of resources to be displayed by the table
* @param props.setData State setter for the list of resources * @param props.setData State setter for the list of resources
*/ */
export default function ResourceTable({ data, setData }: ResourceTableProps) { export default function ResourceTable({
data,
setData,
user,
}: ResourceTableProps) {
const columnHelper = createColumnHelper<Resource>(); const columnHelper = createColumnHelper<Resource>();
// Set up tag handling const [programPresets, setProgramPresets] = useState([
const programProps = useTagsHandler(["community", "domestic", "economic"]); "DOMESTIC",
"COMMUNITY",
"ECONOMIC",
]);
const resourceDetails: Details[] = [
{
key: "name",
label: "name",
inputType: "text",
icon: <UserIcon className="h-4 w-4" />,
},
{
key: "link",
label: "link",
inputType: "email",
icon: <LinkIcon className="h-4 w-4" />,
},
{
key: "program",
label: "program",
inputType: "select-one",
icon: <ListBulletIcon className="h-4 w-4" />,
presetOptionsValues: programPresets,
presetOptionsSetter: setProgramPresets,
},
{
key: "summary",
label: "summary",
inputType: "textarea",
icon: <DocumentTextIcon className="h-4 w-4" />,
},
];
// Define Tanstack columns // Define Tanstack columns
const columns: ColumnDef<Resource, any>[] = [ const columns: ColumnDef<Resource, any>[] = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Name <UserIcon className="inline align-top h-4" /> Name
</> </>
), ),
cell: (info) => ( cell: (info) => (
<RowOpenAction <RowOpenAction
title={info.getValue()} title={info.getValue()}
titleKey="name"
rowData={info.row.original} rowData={info.row.original}
setData={setData} setData={setData}
details={resourceDetails}
isAdmin={user?.role === "ADMIN"}
updateRoute={`/api/resource/update?uuid=${user?.uuid}`}
/> />
), ),
}), }),
columnHelper.accessor("link", { columnHelper.accessor("link", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Link <LinkIcon className="inline align-top h-4" /> Link
</> </>
), ),
cell: (info) => ( cell: (info) => (
<a <div className="flex items-start gap-2 px-2">
href={info.getValue()} <a
target={"_blank"} href={info.getValue()}
className="ml-2 text-gray-500 underline hover:text-gray-400" target="_blank"
> className="text-gray-500 underline hover:text-gray-400 break-all"
{info.getValue()} >
</a> {info.getValue()}
</a>
</div>
), ),
}), }),
columnHelper.accessor("program", { columnHelper.accessor("program", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Program <ListBulletIcon className="inline align-top h-4" /> Program
</> </>
), ),
cell: (info) => ( cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} /> <div className="flex flex-wrap gap-2 items-center px-2">
<Tag>
{info.getValue().length != 0
? info.getValue()
: "no program"}
</Tag>
</div>
), ),
}), }),
columnHelper.accessor("summary", { columnHelper.accessor("summary", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Summary <DocumentTextIcon className="inline align-top h-4" />{" "}
Summary
</> </>
), ),
cell: (info) => ( cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span> <div className="flex items-start gap-2 px-2 py-1">
<span className="text-gray-500 max-h-8 overflow-y-auto">
{info.getValue()}
</span>
</div>
), ),
}), }),
]; ];
return <Table data={data} setData={setData} columns={columns} />; return (
<Table
data={data}
setData={setData}
columns={columns}
details={resourceDetails}
createEndpoint={`/api/resource/create?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"}
/>
);
} }

View File

@ -1,37 +1,43 @@
import Drawer from "@/components/Drawer/Drawer"; import Drawer, { Details } from "@/components/Drawer/Drawer";
import DataPoint from "@/utils/models/DataPoint"; import DataPoint from "@/utils/models/DataPoint";
import {
EnvelopeIcon,
ListBulletIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
type RowOpenActionProps<T extends DataPoint> = { type RowOpenActionProps<T extends DataPoint> = {
title: string; title: string;
titleKey: string;
rowData: T; rowData: T;
setData: Dispatch<SetStateAction<T[]>>; setData: Dispatch<SetStateAction<T[]>>;
details: Details[];
isAdmin?: boolean;
updateRoute: string;
}; };
export function RowOpenAction<T extends DataPoint>({ export function RowOpenAction<T extends DataPoint>({
title, title,
titleKey,
rowData, rowData,
setData, setData,
details,
isAdmin,
updateRoute,
}: RowOpenActionProps<T>) { }: RowOpenActionProps<T>) {
const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent: string) => {
setPageContent(newContent);
};
return ( return (
<div className="font-semibold group flex flex-row items-center justify-between pr-2"> <div className="font-semibold group flex flex-row items-center justify-between pr-2">
{title} {title}
<span> <span>
<Drawer <Drawer
title="My Drawer Title" titleKey={titleKey}
editableContent={pageContent}
rowContent={rowData} rowContent={rowData}
onSave={handleDrawerContentChange} details={details}
setData={setData} setRowContent={setData}
> isAdmin={isAdmin}
{pageContent} updateRoute={updateRoute}
</Drawer> />
</span> </span>
</div> </div>
); );

View File

@ -1,15 +1,23 @@
import { Bars2Icon } from "@heroicons/react/24/solid"; import {
import { Dispatch, SetStateAction } from "react"; Bars2Icon,
import useTagsHandler from "@/components/TagsInput/TagsHandler"; CheckCircleIcon,
DocumentTextIcon,
ListBulletIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table"; import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction"; import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import Service from "@/utils/models/Service"; import Service from "@/utils/models/Service";
import { Details } from "../Drawer/Drawer";
import { Tag } from "../TagsInput/Tag";
import User from "@/utils/models/User";
type ServiceTableProps = { type ServiceTableProps = {
data: Service[]; data: Service[];
setData: Dispatch<SetStateAction<Service[]>>; setData: Dispatch<SetStateAction<Service[]>>;
user?: User;
}; };
/** /**
@ -17,87 +25,177 @@ type ServiceTableProps = {
* @param props.data Stateful list of services to be displayed by the table * @param props.data Stateful list of services to be displayed by the table
* @param props.setData State setter for the list of services * @param props.setData State setter for the list of services
*/ */
export default function ServiceTable({ data, setData }: ServiceTableProps) { export default function ServiceTable({
data,
setData,
user,
}: ServiceTableProps) {
const columnHelper = createColumnHelper<Service>(); const columnHelper = createColumnHelper<Service>();
// Set up tag handling const [programPresets, setProgramPresets] = useState([
const programProps = useTagsHandler(["community", "domestic", "economic"]); "DOMESTIC",
"COMMUNITY",
// TODO: Dynamically or statically get full list of preset requirement tag options "ECONOMIC",
const requirementProps = useTagsHandler([
"anonymous",
"confidential",
"referral required",
"safety assessment",
"intake required",
"income eligibility",
"initial assessment",
]); ]);
const [requirementPresets, setRequirementPresets] = useState([
"Anonymous",
"Confidential",
"Referral required",
"Safety assessment",
"Intake required",
"Income eligibility",
"Initial assessment",
"Insurance accepted",
"Open to parents",
"18+",
"Application required",
"Proof of income",
"Background check",
"Enrollment required",
"Registration required",
"Parental consent",
"Age-appropriate",
"Collaborative",
"Open to the public",
"Registration preferred",
"Legal case",
"Scheduling required",
"Limited availability",
"Eligibility assessment",
"Pre-registration required",
"Commitment to attend",
"Training required",
"Based on individual needs",
]);
const serviceDetails: Details[] = [
{
key: "name",
label: "name",
inputType: "text",
icon: <UserIcon className="inline align-top h-4" />,
},
{
key: "status",
label: "status",
inputType: "text",
icon: <CheckCircleIcon className="inline align-top h-4" />,
},
{
key: "program",
label: "program",
inputType: "select-one",
icon: <ListBulletIcon className="inline align-top h-4" />,
presetOptionsValues: programPresets,
presetOptionsSetter: setProgramPresets,
},
{
key: "requirements",
label: "requirements",
inputType: "select-multiple",
icon: <ListBulletIcon className="inline align-top h-4" />,
presetOptionsValues: requirementPresets,
presetOptionsSetter: setRequirementPresets,
},
{
key: "summary",
label: "summary",
inputType: "textarea",
icon: <DocumentTextIcon className="inline align-top h-4" />,
},
];
// Define Tanstack columns // Define Tanstack columns
const columns: ColumnDef<Service, any>[] = [ const columns: ColumnDef<Service, any>[] = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Name <UserIcon className="inline align-top h-4" /> Name
</> </>
), ),
cell: (info) => ( cell: (info) => (
<RowOpenAction <RowOpenAction
title={info.getValue()} title={info.getValue()}
titleKey="name"
rowData={info.row.original} rowData={info.row.original}
setData={setData} setData={setData}
details={serviceDetails}
updateRoute={`/api/service/update?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"}
/> />
), ),
}), }),
columnHelper.accessor("status", { columnHelper.accessor("status", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Status <CheckCircleIcon className="inline align-top h-4" /> Status
</> </>
), ),
cell: (info) => ( cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span> <span className="text-gray-500 px-2">{info.getValue()}</span>
), ),
}), }),
columnHelper.accessor("program", { columnHelper.accessor("program", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Program <ListBulletIcon className="inline align-top h-4" /> Program
</> </>
), ),
cell: (info) => ( cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} /> <div className="flex flex-wrap gap-2 items-center px-2">
<Tag>
{info.getValue().length != 0
? info.getValue()
: "no program"}
</Tag>
</div>
), ),
}), }),
columnHelper.accessor("requirements", { columnHelper.accessor("requirements", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Requirements <ListBulletIcon className="inline align-top h-4" />{" "}
Requirements
</> </>
), ),
cell: (info) => ( cell: (info) => (
// TODO: Setup different tag handler for requirements <div className="flex flex-wrap gap-2 items-center p-2">
<TagsInput {info.getValue().length > 0 ? (
presetValue={ info.getValue().map((tag: string, index: number) => {
info.getValue()[0] !== "" ? info.getValue() : ["N/A"] return <Tag key={index}>{tag}</Tag>;
} })
{...requirementProps} ) : (
/> <Tag>no requirements</Tag>
)}
</div>
), ),
}), }),
columnHelper.accessor("summary", { columnHelper.accessor("summary", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Summary <DocumentTextIcon className="inline align-top h-4" />{" "}
Summary
</> </>
), ),
cell: (info) => ( cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span> <div className="flex items-start gap-2 px-2 py-1">
<span className="text-gray-500 max-h-8 overflow-y-auto">
{info.getValue()}
</span>
</div>
), ),
}), }),
]; ];
return <Table data={data} setData={setData} columns={columns} />; return (
<Table
data={data}
setData={setData}
columns={columns}
details={serviceDetails}
createEndpoint={`/api/service/create?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"}
/>
);
} }

View File

@ -19,11 +19,35 @@ import { PlusIcon } from "@heroicons/react/24/solid";
import { rankItem } from "@tanstack/match-sorter-utils"; import { rankItem } from "@tanstack/match-sorter-utils";
import { RowOptionMenu } from "./RowOptionMenu"; import { RowOptionMenu } from "./RowOptionMenu";
import DataPoint from "@/utils/models/DataPoint"; import DataPoint from "@/utils/models/DataPoint";
import CreateDrawer from "../Drawer/CreateDrawer";
import { Details } from "../Drawer/Drawer";
type TableProps<T extends DataPoint> = { type TableProps<T extends DataPoint> = {
data: T[]; data: T[];
setData: Dispatch<SetStateAction<T[]>>; setData: Dispatch<SetStateAction<T[]>>;
columns: ColumnDef<T, any>[]; columns: ColumnDef<T, any>[];
details: Details[];
createEndpoint: string;
isAdmin?: boolean;
};
/** Validates that all required fields in a new item have values */
const validateNewItem = (newItem: any, details: Details[]): boolean => {
const hasEmptyFields = details.some((detail) => {
const value = newItem[detail.key];
return (
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0)
);
});
if (hasEmptyFields) {
alert("Please fill in all fields before creating a new item");
return false;
}
return true;
}; };
/** Fuzzy search function */ /** Fuzzy search function */
@ -53,46 +77,59 @@ export default function Table<T extends DataPoint>({
data, data,
setData, setData,
columns, columns,
details,
createEndpoint,
isAdmin = false,
}: TableProps<T>) { }: TableProps<T>) {
console.log(data);
const columnHelper = createColumnHelper<T>(); const columnHelper = createColumnHelper<T>();
/** Sorting function based on visibility */ const createRow = async (newItem: any) => {
const visibilitySort = (a: T, b: T) => const response = await fetch(createEndpoint, {
a.visible === b.visible ? 0 : a.visible ? -1 : 1; method: "POST",
headers: {
// Sort data on load "Content-Type": "application/json",
useEffect(() => { },
setData((prevData) => prevData.sort(visibilitySort)); body: JSON.stringify(newItem),
}, [setData]);
// Data manipulation methods
// TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
const deleteData = (dataId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((data) => data.id !== dataId)
);
};
const hideData = (dataId: number) => {
console.log(`Toggling visibility for data with ID: ${dataId}`);
setData((currentData) => {
const newData = currentData
.map((data) =>
data.id === dataId
? { ...data, visible: !data.visible }
: data
)
.sort(visibilitySort);
console.log(newData);
return newData;
}); });
return response;
}; };
const addData = () => { // /** Sorting function based on visibility */
setData([...data]); // const visibilitySort = (a: T, b: T) =>
}; // a.visible === b.visible ? 0 : a.visible ? -1 : 1;
// // Sort data on load
// useEffect(() => {
// setData((prevData) => prevData.sort(visibilitySort));
// }, [setData]);
// // Data manipulation methods
// // TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
// const deleteData = (dataId: number) => {
// console.log(data);
// setData((currentData) =>
// currentData.filter((data) => data.id !== dataId)
// );
// };
// const hideData = (dataId: number) => {
// console.log(`Toggling visibility for data with ID: ${dataId}`);
// setData((currentData) => {
// const newData = currentData
// .map((data) =>
// data.id === dataId
// ? { ...data, visible: !data.visible }
// : data
// )
// .sort(visibilitySort);
// console.log(newData);
// return newData;
// });
// };
// Add data manipulation options to the first column // Add data manipulation options to the first column
columns.unshift( columns.unshift(
@ -100,8 +137,10 @@ export default function Table<T extends DataPoint>({
id: "options", id: "options",
cell: (props) => ( cell: (props) => (
<RowOptionMenu <RowOptionMenu
onDelete={() => deleteData(props.row.original.id)} onDelete={() => {}}
onHide={() => hideData(props.row.original.id)} onHide={() => {}}
// onDelete={() => deleteData(props.row.original.id)}
// onHide={() => hideData(props.row.original.id)}
/> />
), ),
}) })
@ -114,11 +153,6 @@ export default function Table<T extends DataPoint>({
setQuery(String(target.value)); setQuery(String(target.value));
}; };
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering // TODO: Filtering
// TODO: Sorting // TODO: Sorting
@ -138,16 +172,6 @@ export default function Table<T extends DataPoint>({
getCoreRowModel: getCoreRowModel(), 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 ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
@ -206,18 +230,37 @@ export default function Table<T extends DataPoint>({
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> {isAdmin && ( // Only show create drawer for admins
<td <tr>
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50" <td
colSpan={100} className="p-3 border-y border-gray-200"
onClick={addData} colSpan={100}
> >
<span className="flex ml-1 text-gray-500"> <CreateDrawer
<PlusIcon className="inline h-4 mr-1" /> details={details}
New onCreate={(newItem) => {
</span> if (
</td> !validateNewItem(newItem, details)
</tr> ) {
return false;
}
createRow(newItem).then((response) => {
if (response.ok) {
newItem.visible = true;
setData((prev) => [
...prev,
newItem,
]);
}
});
return true;
}}
/>
</td>
</tr>
)}
</tfoot> </tfoot>
</table> </table>
</div> </div>

View File

@ -1,19 +1,20 @@
import { import {
ArrowDownCircleIcon, EnvelopeIcon,
AtSymbolIcon, ListBulletIcon,
Bars2Icon, UserIcon,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table"; import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction"; import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { Details } from "../Drawer/Drawer";
import { Tag } from "../TagsInput/Tag";
type UserTableProps = { type UserTableProps = {
data: User[]; data: User[];
setData: Dispatch<SetStateAction<User[]>>; setData: Dispatch<SetStateAction<User[]>>;
user?: User;
}; };
/** /**
@ -21,49 +22,92 @@ type UserTableProps = {
* @param props.data Stateful list of users to be displayed by the table * @param props.data Stateful list of users to be displayed by the table
* @param props.setData State setter for the list of users * @param props.setData State setter for the list of users
*/ */
export default function UserTable({ data, setData }: UserTableProps) { export default function UserTable({ data, setData, user }: UserTableProps) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
// Set up tag handling const [rolePresets, setRolePresets] = useState([
const roleProps = useTagsHandler([ "ADMIN",
"administrator", "VOLUNTEER",
"volunteer", "EMPLOYEE",
"employee",
]); ]);
const programProps = useTagsHandler(["community", "domestic", "economic"]); const [programPresets, setProgramPresets] = useState([
"DOMESTIC",
"COMMUNITY",
"ECONOMIC",
]);
const userDetails: Details[] = [
{
key: "username",
label: "username",
inputType: "text",
icon: <UserIcon className="h-4 w-4" />,
},
{
key: "role",
label: "role",
inputType: "select-one",
icon: <ListBulletIcon className="h-4 w-4" />,
presetOptionsValues: rolePresets,
presetOptionsSetter: setRolePresets,
},
{
key: "email",
label: "email",
inputType: "email",
icon: <EnvelopeIcon className="h-4 w-4" />,
},
{
key: "program",
label: "program",
inputType: "select-multiple",
icon: <ListBulletIcon className="h-4 w-4" />,
presetOptionsValues: programPresets,
presetOptionsSetter: setProgramPresets,
},
];
// Define Tanstack columns // Define Tanstack columns
const columns: ColumnDef<User, any>[] = [ const columns: ColumnDef<User, any>[] = [
columnHelper.accessor("username", { columnHelper.accessor("username", {
header: () => ( header: () => (
<> <>
<Bars2Icon className="inline align-top h-4" /> Username <UserIcon className="inline align-top h-4" /> Username
</> </>
), ),
cell: (info) => ( cell: (info) => (
<RowOpenAction <RowOpenAction
title={info.getValue()} title={info.getValue()}
titleKey="username"
rowData={info.row.original} rowData={info.row.original}
setData={setData} setData={setData}
details={userDetails}
isAdmin={user?.role === "ADMIN"}
updateRoute={`/api/user/update?uuid=${user?.uuid}`}
/> />
), ),
}), }),
columnHelper.accessor("role", { columnHelper.accessor("role", {
header: () => ( header: () => (
<> <>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "} <ListBulletIcon className="inline align-top h-4" /> Role
Role
</> </>
), ),
cell: (info) => ( cell: (info) => (
<TagsInput presetValue={info.getValue()} {...roleProps} /> <div className="flex flex-wrap gap-2 items-center px-2">
<Tag>
{info.getValue() && info.getValue().length != 0
? info.getValue()
: "no role"}
</Tag>
</div>
), ),
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: () => ( header: () => (
<> <>
<AtSymbolIcon className="inline align-top h-4" /> Email <EnvelopeIcon className="inline align-top h-4" /> Email
</> </>
), ),
cell: (info) => ( cell: (info) => (
@ -75,15 +119,31 @@ export default function UserTable({ data, setData }: UserTableProps) {
columnHelper.accessor("program", { columnHelper.accessor("program", {
header: () => ( header: () => (
<> <>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "} <ListBulletIcon className="inline align-top h-4" /> Program
Program
</> </>
), ),
cell: (info) => ( cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} /> <div className="flex p-2 flex-wrap gap-2 items-center">
{info.getValue().length > 0 ? (
info.getValue().map((tag: string, index: number) => {
return <Tag key={index}>{tag}</Tag>;
})
) : (
<Tag>no programs</Tag>
)}
</div>
), ),
}), }),
]; ];
return <Table<User> data={data} setData={setData} columns={columns} />; return (
<Table<User>
data={data}
setData={setData}
columns={columns}
details={userDetails}
createEndpoint={`/api/user/create?uuid=${user?.uuid}`}
isAdmin={user?.role === "ADMIN"}
/>
);
} }

View File

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

View File

@ -1,11 +1,21 @@
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid"; import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
import { useState } from "react"; import { useState } from "react";
export const DropdownAction = ({ tag, handleDeleteTag, handleEditTag }) => { interface DropdownActionProps {
tag: string;
handleDeleteTag: (tag: string) => void;
handleEditTag: (oldTag: string, newTag: string) => void;
}
export const DropdownAction = ({
tag,
handleDeleteTag,
handleEditTag,
}: DropdownActionProps) => {
const [isVisible, setVisible] = useState(false); const [isVisible, setVisible] = useState(false);
const [inputValue, setInputValue] = useState(tag); const [inputValue, setInputValue] = useState(tag);
const editTagOption = (e) => { const editTagOption = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
handleEditTag(tag, inputValue); handleEditTag(tag, inputValue);
setVisible(false); setVisible(false);

View File

@ -6,51 +6,52 @@ import { CreateNewTagAction } from "./CreateNewTagAction";
interface TagsInputProps { interface TagsInputProps {
presetOptions: string[]; presetOptions: string[];
presetValue: string | string[]; presetValue: string[];
setPresetOptions: Dispatch<SetStateAction<string[]>>; setPresetOptions: Dispatch<SetStateAction<string[]>>;
getTagColor(tag: string): string; onTagsChange?: (tags: Set<string>) => void;
singleValue?: boolean;
} }
const TagsInput: React.FC<TagsInputProps> = ({ const TagsInput: React.FC<TagsInputProps> = ({
presetValue, presetValue,
presetOptions, presetOptions,
setPresetOptions, setPresetOptions,
getTagColor, onTagsChange,
singleValue = false,
}) => { }) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [cellSelected, setCellSelected] = useState(false); const [cellSelected, setCellSelected] = useState(false);
const [tags, setTags] = useState<Set<string>>(
typeof presetValue === "string" // TODO: Add tags to the database and remove the presetValue and lowercasing
? new Set([presetValue]) const [tags, setTags] = useState<Set<string>>(new Set(presetValue));
: new Set(presetValue)
);
const [options, setOptions] = useState<Set<string>>(new Set(presetOptions)); const [options, setOptions] = useState<Set<string>>(new Set(presetOptions));
const [filteredOptions, setFilteredOptions] = useState<Set<string>>(
new Set(presetOptions)
);
const dropdown = useRef<HTMLDivElement>(null); const dropdown = useRef<HTMLDivElement>(null);
const handleClick = () => { const handleClick = () => {
if (!cellSelected) { if (!cellSelected) {
setCellSelected(true); setCellSelected(true);
// Add event listener only after setting cellSelected to true
setTimeout(() => { setTimeout(() => {
window.addEventListener("click", handleOutsideClick); window.addEventListener("click", handleOutsideClick);
}, 100); }, 100);
} }
}; };
// TODO: Fix MouseEvent type and remove the as Node as that is completely wrong
const handleOutsideClick = (event: MouseEvent) => { const handleOutsideClick = (event: MouseEvent) => {
if ( if (
dropdown.current && dropdown.current &&
!dropdown.current.contains(event.target as Node) !dropdown.current.contains(event.target as Node)
) { ) {
console.log("outside");
setCellSelected(false); setCellSelected(false);
// Remove event listener after handling outside click
window.removeEventListener("click", handleOutsideClick); window.removeEventListener("click", handleOutsideClick);
} }
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setOptions(() => { setFilteredOptions(() => {
const newOptions = presetOptions.filter((item) => const newOptions = presetOptions.filter((item) =>
item.includes(e.target.value.toLowerCase()) item.includes(e.target.value.toLowerCase())
); );
@ -61,39 +62,58 @@ const TagsInput: React.FC<TagsInputProps> = ({
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) { if (e.key === "Enter" && inputValue.trim()) {
// setPresetOptions((prevPreset) => { if (singleValue && tags.size >= 1) {
// const uniqueSet = new Set(presetOptions); // Don't add new tag if we're in single value mode and already have a tag
// uniqueSet.add(inputValue); return;
// return Array.from(uniqueSet); }
// }); addTag(e);
setTags((prevTags) => new Set(prevTags).add(inputValue));
setOptions((prevOptions) => new Set(prevOptions).add(inputValue));
setInputValue("");
} }
}; };
const addTag = (e?: React.KeyboardEvent<HTMLInputElement>) => {
e?.stopPropagation();
const newTags = new Set(Array.from(tags).concat(inputValue));
setOptions(new Set(Array.from(options).concat(inputValue)));
setTags(newTags);
setFilteredOptions(new Set(Array.from(options).concat(inputValue)));
setInputValue("");
onTagsChange?.(newTags);
};
const handleSelectTag = (tagToAdd: string) => { const handleSelectTag = (tagToAdd: string) => {
if (!tags.has(tagToAdd)) { if (singleValue) {
// Corrected syntax for checking if a Set contains an item const newTags = new Set([tagToAdd]);
setTags((prevTags) => new Set(prevTags).add(tagToAdd)); setTags(newTags);
onTagsChange?.(newTags);
} else if (!tags.has(tagToAdd)) {
const newTags = new Set(Array.from(tags).concat(tagToAdd));
setTags(newTags);
onTagsChange?.(newTags);
} }
}; };
const handleDeleteTag = (tagToDelete: string) => { const handleDeleteTag = (tagToDelete: string) => {
setTags((prevTags) => { const newTags = new Set(
const updatedTags = new Set(prevTags); Array.from(tags).filter((tag) => tag !== tagToDelete)
updatedTags.delete(tagToDelete); );
return updatedTags; setTags(newTags);
}); onTagsChange?.(newTags);
}; };
const handleDeleteTagOption = (tagToDelete: string) => { const handleDeleteTagOption = (tagToDelete: string) => {
// setPresetOptions(presetOptions.filter(tag => tag !== tagToDelete)); setOptions(
setOptions((prevOptions) => { new Set(
const updatedOptions = new Set(prevOptions); Array.from(options).filter((option) => option !== tagToDelete)
updatedOptions.delete(tagToDelete); )
return updatedOptions; );
});
setFilteredOptions(
new Set(
Array.from(options).filter((option) => option !== tagToDelete)
)
);
if (tags.has(tagToDelete)) { if (tags.has(tagToDelete)) {
handleDeleteTag(tagToDelete); handleDeleteTag(tagToDelete);
} }
@ -101,23 +121,20 @@ const TagsInput: React.FC<TagsInputProps> = ({
const handleEditTag = (oldTag: string, newTag: string) => { const handleEditTag = (oldTag: string, newTag: string) => {
if (oldTag !== newTag) { if (oldTag !== newTag) {
setTags((prevTags) => { setTags(
const tagsArray = Array.from(prevTags); new Set(
const oldTagIndex = tagsArray.indexOf(oldTag); Array.from(tags)
if (oldTagIndex !== -1) { .filter((tag) => tag !== oldTag)
tagsArray.splice(oldTagIndex, 1, newTag); .concat(newTag)
} )
return new Set(tagsArray); );
}); setOptions(
new Set(
setOptions((prevOptions) => { Array.from(options)
const optionsArray = Array.from(prevOptions); .filter((option) => option !== oldTag)
const oldTagIndex = optionsArray.indexOf(oldTag); .concat(newTag)
if (oldTagIndex !== -1) { )
optionsArray.splice(oldTagIndex, 1, newTag); );
}
return new Set(optionsArray);
});
} }
}; };
@ -139,28 +156,45 @@ const TagsInput: React.FC<TagsInputProps> = ({
active active
tags={tags} tags={tags}
/> />
<input {(!singleValue || tags.size === 0) && (
type="text" <input
value={inputValue} type="text"
placeholder="Search for an option..." value={inputValue}
onChange={handleInputChange} placeholder={
onKeyDown={handleAddTag} singleValue && tags.size > 0
className="focus:outline-none bg-transparent" ? ""
autoFocus : "Search for an option..."
/> }
onChange={handleInputChange}
onKeyDown={handleAddTag}
className="focus:outline-none bg-transparent"
autoFocus
/>
)}
</div> </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"> <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"> <p className="capitalize">
Select an option or create one {singleValue && tags.size > 0
? "Only one option can be selected"
: "Select an option or create one"}
</p> </p>
<TagDropdown {(!singleValue || tags.size === 0) && (
handleDeleteTag={handleDeleteTagOption} <>
handleEditTag={handleEditTag} <TagDropdown
handleAdd={handleSelectTag} handleDeleteTag={
tags={options} handleDeleteTagOption
/> }
{inputValue.length > 0 && ( handleEditTag={handleEditTag}
<CreateNewTagAction input={inputValue} /> handleAdd={handleSelectTag}
tags={filteredOptions}
/>
{inputValue.length > 0 && (
<CreateNewTagAction
input={inputValue}
addTag={addTag}
/>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@ -1,14 +1,26 @@
import { XMarkIcon } from "@heroicons/react/24/solid"; import { XMarkIcon } from "@heroicons/react/24/solid";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
export const Tag = ({ children, handleDelete, active = false }) => { interface TagProps {
children: string;
handleDelete?: (tag: string) => void;
active?: boolean;
}
export const Tag = ({ children, handleDelete, active = false }: TagProps) => {
return ( return (
<span <span
className={`font-normal bg-purple-100 text-gray-800 flex flex-row p-1 px-2 rounded-lg`} className={`font-normal bg-purple-100 text-gray-800 flex flex-row p-1 px-2 rounded-lg`}
style={{ textTransform: "none" }}
> >
{children} {children}
{active && handleDelete && ( {active && handleDelete && (
<button onClick={() => handleDelete(children)}> <button
onClick={(e) => {
e.stopPropagation();
handleDelete(children);
}}
>
<XMarkIcon className={`ml-1 w-3 text-purple-500`} /> <XMarkIcon className={`ml-1 w-3 text-purple-500`} />
</button> </button>
)} )}

View File

@ -1,29 +1,42 @@
import { Tag } from "./Tag"; import { Tag } from "./Tag";
import { DropdownAction } from "./DropdownAction"; import { DropdownAction } from "./DropdownAction";
interface TagDropdownProps {
tags: Set<string>;
handleEditTag: (oldTag: string, newTag: string) => void;
handleDeleteTag: (tag: string) => void;
handleAdd: (tag: string) => void;
}
export const TagDropdown = ({ export const TagDropdown = ({
tags, tags,
handleEditTag, handleEditTag,
handleDeleteTag, handleDeleteTag,
handleAdd, handleAdd,
}) => { }: TagDropdownProps) => {
return ( return (
<div className="z-50 flex flex-col space-y-2 mt-2"> <div className="z-50 flex flex-col space-y-2 mt-2 max-h-60 overflow-y-auto scrollbar-thin scrollbar-track-gray-100 scrollbar-thumb-gray-300 pr-2">
{Array.from(tags).map((tag, index) => ( {Array.from(tags).length > 0 ? (
<div Array.from(tags).map((tag, index) => (
key={index} <div
className="items-center rounded-md p-1 flex flex-row justify-between hover:bg-gray-100" 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 onClick={() => handleAdd(tag)}>
</button> <Tag>{tag}</Tag>
<DropdownAction </button>
handleDeleteTag={handleDeleteTag} <DropdownAction
handleEditTag={handleEditTag} handleDeleteTag={handleDeleteTag}
tag={tag} handleEditTag={handleEditTag}
/> tag={tag}
/>
</div>
))
) : (
<div className="text-gray-500 text-sm p-1">
No options available. Type to create new ones.
</div> </div>
))} )}
</div> </div>
); );
}; };

View File

@ -7,21 +7,25 @@ export interface Tags {
} }
export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => { export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => {
// console.log(tags);
return ( return (
<div className="flex ml-2 flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center min-h-[24px] min-w-[100px] rounded-md hover:bg-gray-100 p-1">
{Array.from(tags).map((tag, index) => { {Array.from(tags).length > 0 ? (
return ( Array.from(tags).map((tag, index) => {
<Tag return (
handleDelete={handleDelete} <Tag
active={active} handleDelete={handleDelete}
key={index} active={active}
> key={index}
{tag} >
</Tag> {tag}
); </Tag>
})} );
})
) : (
<span className="text-gray-400 text-sm cursor-pointer">
Click to select tags
</span>
)}
</div> </div>
); );
}; };

View File

@ -11,9 +11,15 @@ const Loading = () => {
alt="Compass Center logo." alt="Compass Center logo."
width={100} width={100}
height={91} height={91}
style={{ height: "auto", width: "auto" }}
priority
/> />
<h1 className={styles.loadingTitle}>Loading...</h1> <h1 className="text-2xl font-semibold text-gray-700 mt-4 mb-6">
<div className={styles.loadingSpinner}></div> Loading...
</h1>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-24 w-24 border-b-2 border-gray-700"></div>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@ -1,247 +0,0 @@
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

@ -23,8 +23,8 @@ export const SearchResult: React.FC<SearchResultProps> = ({
type === "resource" type === "resource"
? BookmarkIcon ? BookmarkIcon
: type === "service" : type === "service"
? ClipboardIcon ? ClipboardIcon
: QuestionMarkCircleIcon; // Unknown type : QuestionMarkCircleIcon; // Unknown type
return ( return (
<div className="flex justify-between items-center w-full p-2 rounded-md hover:bg-purple-100 cursor-pointer group"> <div className="flex justify-between items-center w-full p-2 rounded-md hover:bg-purple-100 cursor-pointer group">

View File

@ -1,15 +1,21 @@
import { useState } from "react";
import { signOut } from "@/app/auth/actions"; import { signOut } from "@/app/auth/actions";
interface UserProfileProps { interface UserProfileProps {
name: string; name: string;
email: string; email: string;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
} }
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = async (
event: React.MouseEvent<HTMLButtonElement>,
setLoading: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoading(true);
await signOut(); await signOut();
}; };
export const UserProfile = ({ name, email }: UserProfileProps) => { export const UserProfile = ({ name, email, setLoading }: UserProfileProps) => {
return ( return (
<div className="flex flex-col items-start space-y-2"> <div className="flex flex-col items-start space-y-2">
<div className="flex flex-col"> <div className="flex flex-col">
@ -19,7 +25,7 @@ export const UserProfile = ({ name, email }: UserProfileProps) => {
<span className="text-xs text-gray-500">{email}</span> <span className="text-xs text-gray-500">{email}</span>
</div> </div>
<button <button
onClick={handleClick} onClick={(event) => handleClick(event, setLoading)}
className="text-red-600 font-semibold text-xs hover:underline mt-1" className="text-red-600 font-semibold text-xs hover:underline mt-1"
> >
Sign out Sign out

View File

@ -1,32 +1,32 @@
[ [
{ {
"type": "resource", "type": "resource",
"name": "example name", "name": "example name",
"description": "example description" "description": "example description"
}, },
{ {
"type": "service", "type": "service",
"name": "example name", "name": "example name",
"description": "example description" "description": "example description"
}, },
{ {
"type": "resource", "type": "resource",
"name": "National Domestic Violence Hotline", "name": "National Domestic Violence Hotline",
"description": "24/7 confidential support for victims of domestic violence" "description": "24/7 confidential support for victims of domestic violence"
}, },
{ {
"type": "resource", "type": "resource",
"name": "Legal Aid Society", "name": "Legal Aid Society",
"description": "Free legal assistance for low-income individuals" "description": "Free legal assistance for low-income individuals"
}, },
{ {
"type": "service", "type": "service",
"name": "Crisis Hotline", "name": "Crisis Hotline",
"description": "24/7 support for individuals in crisis" "description": "24/7 support for individuals in crisis"
}, },
{ {
"type": "unknown", "type": "unknown",
"name": "unknown thing with a really long name", "name": "unknown thing with a really long name",
"description": "and let's also type out a really long description to see how it handles overflow and all that anyways" "description": "and let's also type out a really long description to see how it handles overflow and all that anyways"
} }
] ]

View File

@ -21,7 +21,7 @@ const config: Config = {
}, },
}, },
}, },
plugins: [], plugins: [require("tailwind-scrollbar")],
}; };
export default config; export default config;

View File

@ -1,5 +1,7 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server"; import { User } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import { Role } from "../models/User";
export async function updateSession(request: NextRequest) { export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ let response = NextResponse.next({
@ -54,7 +56,50 @@ export async function updateSession(request: NextRequest) {
} }
); );
await supabase.auth.getUser(); const { data, error } = await supabase.auth.getUser();
const authenticatedRoutes = ["/admin", "/resource", "/home", "/service"];
const pathname = request.nextUrl.pathname;
for (const route of authenticatedRoutes) {
if (error && pathname.startsWith(route)) {
console.log("redirected");
return NextResponse.redirect(
new URL(
"/auth/login",
request.nextUrl.protocol + "//" + request.nextUrl.host
)
);
}
}
if (pathname.startsWith("/admin") && data.user) {
// After the previous checks we can assume the user is not empty
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("redirected as not admin");
return NextResponse.redirect(
new URL(
"/home",
request.nextUrl.protocol + "//" + request.nextUrl.host
)
);
}
}
if (data.user && pathname.startsWith("/auth/login")) {
return NextResponse.redirect(
new URL(
"/home",
request.nextUrl.protocol + "//" + request.nextUrl.host
)
);
}
return response; return response;
} }

26
package-lock.json generated
View File

@ -1,12 +1,13 @@
{ {
"name": "compass", "name": "workspace",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2" "lint-staged": "^15.2.2",
"tailwind-scrollbar": "^4.0.0-beta.0"
} }
}, },
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
@ -649,6 +650,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/tailwind-scrollbar": {
"version": "4.0.0-beta.0",
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.0-beta.0.tgz",
"integrity": "sha512-d6qwt3rYDgsKNaQGLW0P6N1TN/87xYZDjH6/PimtFvij2NgC5i3M6mEuVKR4Ixb2u3SvMBT95t7+xzJGJRzXtA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.13.0"
},
"peerDependencies": {
"tailwindcss": "^4.0.0-beta.8"
}
},
"node_modules/tailwindcss": {
"version": "4.0.0-beta.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0-beta.8.tgz",
"integrity": "sha512-21HmdRq9tHDLJZavb2cRBGJxBvRODpwb0/t3tRbMOl65hJE6zG6K6lD6lLS3IOC35u4SOjKjdZiJJi9AuWCf+Q==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@ -1,21 +1,22 @@
{ {
"devDependencies": { "devDependencies": {
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2" "lint-staged": "^15.2.2",
"tailwind-scrollbar": "^4.0.0-beta.0"
}, },
"scripts": { "scripts": {
"precommit": "lint-staged" "precommit": "lint-staged"
}, },
"lint-staged": { "lint-staged": {
"*.ts": [ "*.ts": [
"cd compass", "cd compass",
"prettier --write", "prettier --write",
"git add" "git add"
], ],
"*.tsx": [ "*.tsx": [
"cd compass", "cd compass",
"prettier --write", "prettier --write",
"git add" "git add"
] ]
} }
} }