A task management toolf for small teams or small companies The current implemenation is based on the the needs of a property management team but can be made as generic as needed
working in in property mangement role exposesd som eof the super specific issues that the app is trying to odder more elegant solutions to
- the ability to create/edit/update tasks with scoed roles for all the staff and still have everyone on the same page
- leave requset and a nice dashboard that allows you to pick days noy yet picked by other staff for your next leave
Front-end is in React , and given most of it will be behind a login screen it's all client rendered - vite and typescript - tailwindcss - react-router for the routing
- react-query for erver state management
- rect-select, react-calenedr , react-modal
- dayjs for date formating
- playwringth ror E2E tests
Back-end is in Pocketbase and open source BAAS written in Go , with JS and Flutter SDKS. it's fast highly extensible with the hooks thye expose and has it all databse/storage/authentication + ouath providers ...
- Home route : the default route , tasks will be loaded here
- Auth route : the login route
- Portal route : the portal route , leave requests will be done here
- Staff route: the staff page
Expand staff interface
export interface StaffResponse {
id: string
collectionId: string
collectionName: string
username: string
verified: boolean
emailVisibility: boolean
email: string
created: string
updated: string
name: string
type: string
avatar: string
}
export interface StaffLeaveResponse {
id: string
collectionId: string
collectionName: string
created: string
updated: string
leave_type: "sick" | "annual" | "maternity" | "other";
leave_reason: string
leave_start: string
leave_end: string
leave_requested_by: string
leave_approved_by: string
leave_approved_on?: string
leave_rejected_on?: string
leave_rejected_by?: string
leave_request_status: "approved" | "rejected" | "pending";
remaining_leave_days: number
remaining_sick_leave_days: number
expand:StaffLeaveResponseExpand
}
interface StaffLeaveResponseExpand{
leave_approved_by:StaffResponse;
leave_requested_by:StaffResponse;
}
export interface StaffLeaveMutationFields {
leave_type: "sick" | "annual" | "maternity" | "other";
leave_reason: string
leave_start: string
leave_end: string
leave_requested_by: string
leave_approved_by?: string
leave_approved_on?:string
leave_rejected_on?:string
leave_rejected_by?:string
leave_request_status: "approved" | "rejected" | "pending";
remaining_leave_days: number
remaining_sick_leave_days: number
}
Click to expand task interface
export interface TasksResponse {
id: string
collectionId: string
collectionName: string
created: string
updated: string
title: string
description: string
type:"todo" |"repairs" | "maintenance" | "recurring" | "other";
status: "created" | "approved" | "funded" |"in_progress" | "completed" | "rejected";
frequency?:"once"|"daily"|"weekly"|"monthly"|"yearly"|"never"
created_by: string
updated_by?: string
approved_on?: string
approved_by?: string
funded_on?: string
funded_by?: string
marked_in_progress_on?:string
rejected_on?:string
completed_on?: string
marked_completed_by?: string
rejected_by?:string
marked_in_progress_by?:string
quotation?: string
deadline?: string
should_email:boolean
expand?:StaffExpand
}
- tanstack-quert v5 beta , now lets you define query invalidation logic directily in the global query client config
const queryClient:QueryClient = new QueryClient({
mutationCache:new MutationCache({
onSuccess: async (data, variable, context, mutation) => {
if (Array.isArray(mutation.meta?.invalidates)) {
return queryClient.invalidateQueries({
queryKey:mutation.meta?.invalidates
})
}
}
})
})
and now if we pass in a meta (array of stirings that we used as the queryKey)
const mutation = useMutation({
mutationFn: (input:StaffLeaveMutationFields) => addStaffLeaveRequest(input),
meta: {
invalidates: ["staff_leaves", " "]
},
onError(error, variables, context) {
setError({ name: "main", message: concatErrors(error) });
},
onSuccess(data, variables, context) {
store.updateNotification({type:"success",message:"leave request successfully sent"})
setOpen(false)
},
})
we ca also define a finer invalidation that populates the cache with the mutation response instaed if an entire refetch like is happening in the above code
const queryClient:QueryClient = new QueryClient({
mutationCache:new MutationCache({
onSuccess: async (data, variable, context, mutation) => {
// to update query cache list items by pocketbase pagianted list queries
if (Array.isArray(mutation.meta?.updatelistitems)) {
const update_list_key = mutation.meta?.updatelistitems as string[]
return queryClient.setQueryData(update_list_key, (oldData?:ListResult<Record>) => {
const q_data = data as Record
if (q_data.id && oldData) {
const updatedItems = oldData.items.map((item) => {
if (item.id === q_data.id) {
// Return the new object if the id matches
return q_data;
}
// Otherwise, return the current item
return item;
});
// Return the updated data with the new items array
return {
...oldData,
items: updatedItems,
};
}
return oldData
})
}
}
})
})
Every react-query function needs a queryKey and a queryFn that returns a promise
it can be expressed as
const getStuff = async () => {
try {
const res = await fetch('https://dummyjson.com/todos')
const stuff = await res.json()
return stuff
} catch (error) {
throw error
}
}
const query = useQuery({
queryKey:['stuff'],
queryFn:getStuff
})
oR to fetch one item from the api
const getStuff = async (id:number) => {
try {
const res = await fetch(`https://dummyjson.com/todos/${id}`)
const stuff = await res.json()
return stuff
} catch (error) {
throw error
}
}
cost id = 1
const query = useQuery({
queryKey:['stuff',id],
queryFn:()=>getStuff(id)
})
both n infifnte queries is valide but there's some extra params that get injected into our callback function which would be easier to access/type properly using the d2dn method
type InjectedQueryFnProps = {
queryKey: any[];
signal: AbortSignal;
pageParam: number;
meta: Record<string, unknown> | undefined;
}
const getStuff = async (id:number,params:InjectedQueryFnProps) => {
try {
const res = await fetch(`https://dummyjson.com/todos/${id}`)
const stuff = await res.json()
return stuff
} catch (error) {
throw error
}
}
cost id = 1
const query = useInfiniteQuery({
queryKey:['stuff',id],
queryFn:(params:InjectedQueryFnProps)=>getStuff(id,params)
})
the pocketbase API also autogenerates REST APIS for you which include a paginated one which we can take advantage of in the above code example
export async function getStaff(props: InjectedQueryFnProps, keyword?: string) {
try {
const resultList = await pb.collection('staff').getList<StaffResponse>(props.pageParam,50, {
filter: `name ~"${keyword}"`,
sort: '-created',
});
return resultList
} catch (error) {
throw error;
}
}
with out writing anymore lofiv hitting the load more button loads cancats your list of staff to the existing one for that infinite scroll
they can be of three types
// fetch a paginated records list
const resultList = await pb.collection('tasks').getList(1, 50, {
filter: 'created >= "2022-01-01 00:00:00" && someField1 != someField2',
});
// you can also fetch all records at once via getFullList
const records = await pb.collection('tasks').getFullList({
sort: '-created',
});
// or fetch only the first record that matches the specified filter
const record = await pb.collection('tasks').getFirstListItem('someField="test"', {
expand: 'relField1,relField2.subRelField',
})
all the methods support generics and will give you a typed response which is awesome
interface TaskResponse{
id:string,
......
}
// fetch a paginated records list
const resultList = await pb.collection('tasks').getList<TaskResponse>(1, 50, {
filter: 'created >= "2022-01-01 00:00:00" && someField1 != someField2',
});
// you can also fetch all records at once via getFullList
const records = await pb.collection('tasks').getFullList<TaskResponse>({
sort: '-created',
});
// or fetch only the first record that matches the specified filter
const record = await pb.collection('tasks').getFirstListItem<TaskResponse>('someField="test"', {
expand: 'relField1,relField2.subRelField',
})
- image thumbnail generation: this is a sidenote but if you have a media heavy app you can use pocketbase's ability to return an image thumbnail by adding thb='100x100' to the query string , thus allows you to load a poor quality image as the placeholder while the main image loads
You can add a task of type TODO or repair . the main difference is that Reairs need approval and funding steps while todos can only be marked complete the approve/reject process is reserved for mangers only and funding is for cashiers , the respective buttons will be disabled with messages indicating who can perfom that task
This is also enforcedin the backed using Pocketbases BeforeUpdateRecordHook
VClick to expand code snippet
// record_update_handler.go
package main
import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
)
func handleRecordUpdate(e *core.RecordUpdateEvent) error {
// ignore checks if an admin is logged in
admin, _ := e.HttpContext.Get(apis.ContextAdminKey).(*models.Admin)
if admin != nil {
return nil
}
// before updating tasks tasks collecton
if e.Record.Collection().Name == "tasks" {
// check for the user logged in
authRecord, _ := e.HttpContext.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
return apis.NewForbiddenError("Only auth records can access this endpoint", nil)
}
// validating that mangers only are approving/rejecting
if authRecord.GetString("type") != "manager" &&
(e.Record.GetString("status") == "approved" || e.Record.GetString("status") == "rejected") {
return apis.NewBadRequestError("Approving/Rejecting is a Manager only action.", nil)
}
// validating that cashiers only are funding
if authRecord.GetString("type") != "cashier" && e.Record.GetString("status") == "funded" {
return apis.NewBadRequestError("Funding is a Cashier only action.", nil)
}
}
// checks for staff_details collection
if e.Record.Collection().Name == "staff_details" {
authRecord, _ := e.HttpContext.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
return apis.NewForbiddenError("Only auth records can access this endpoint", nil)
}
// validating that mangers only are approving/rejecting
if authRecord.GetString("type") != "manager" &&
(e.Record.GetString("leave_request_status") == "approved" ||
e.Record.GetString("leave_request_status") == "rejected") {
return apis.NewBadRequestError("Approving/Rejecting is a Manager only action.", nil)
}
}
return nil
}
under portal is a staff management page where you can requsets for a leave and your manager aacn then approve/reject it. It stops you from requesting a new leave if you already have a pending request
Click to expand code snippet
// record_update_handler.go
package main
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
)
func handleRecordCreate(e *core.RecordCreateEvent , app *pocketbase.PocketBase) error {
// Ignore checks if an admin is logged in
admin, _ := e.HttpContext.Get(apis.ContextAdminKey).(*models.Admin)
if admin != nil {
return nil
}
// Check for staff_details collection
if e.Record.Collection().Name == "staff_details" {
// Check if the user has requested any leaves with status equal to pending
authRecord, _ := e.HttpContext.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
return apis.NewForbiddenError("Only auth records can access this endpoint", nil)
}
// Check if the user has requested any leaves with status equal to pending
var total int
err := app.DB().
Select("count(*)").
From("staff_details").
AndWhere(dbx.HashExp{"leave_request_status": "pending"}).
AndWhere(dbx.HashExp{"leave_requested_by":authRecord.GetId()}).
Row(&total)
if err != nil {
return err
}
if total > 0 {
return apis.NewBadRequestError("User already has pending leave requests", nil)
}
}
return nil
}
it also makes use of React Calender to hightlight the days that have already been picked and disable them on the calender view
function tileDisabled({ date, view }:CalendarTileProperties) {
const disabledRanges=taken_leave_ranges?.map((range)=>{
return [new Date(range[0]),new Date(range[1])]
})
if (view === 'month') {
if(disabledRanges)
return isWithinRanges(date, disabledRanges);
}
return false
}
function isWithinRange(date:Date, range:Date[]) {
return isWithinInterval(date, { start: range[0], end: range[1] });
}
function isWithinRanges(date:Date, ranges:Date[][]){
return ranges.some(range => isWithinRange(date, range));
}
then we passit into the calender
<Calendar
value={rngs.dateRange as [Date | null, Date | null]}
onChange={rngs.updateDateRange}
selectRange={true}
tileDisabled={tileDisabled}
/>
Forms can be quite messyy in react , so i made a bunch of custom inputs and a custom hook to tie them all together
community made solutions like react-hook-form always had gotchas that would bite you've already migrarted so much of your code to it. it's use of useRef made it bad for instances where i needed it to re-render the page on every keystroke , for example having a button that's disaled until something is typed into an inutbox took me forever to try to implemnt unsuccefully until i just reverted back to useStae based form management and it worked so i don't consider it anymore
import { UseMutationResult } from "@tanstack/react-query";
import { useState } from "react";
interface UseCustomFormProps<T,R> {
initialValues:T;
mutation:UseMutationResult<R, Error,T, unknown>
inputValidation(inpt: T, setError: React.Dispatch<React.SetStateAction<{
name: string;
message: string;
}>>): boolean
}
export function useCustomForm<T,R>({initialValues,mutation,inputValidation}:UseCustomFormProps<T,R>){
const [input, setInput] = useState<T>(initialValues);
const [error, setError] = useState({ name: "", message: "" })
const [success, setSuccess] = useState<R|undefined>()
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>){
setInput((prev) => {
return { ...prev, [e.target.id]: e.target.value };
});
if (error.message !== "" || error.name !== "") {
setError({ name: "", message: "" });
}
};
function handleSubmit(e: React.ChangeEvent<HTMLFormElement>){
e.preventDefault();
if(inputValidation(input,setError)){
mutation.mutate(input, {
onSuccess(data, variables, context) {
setSuccess(data)
},
});
}
};
return { handleChange,handleSubmit,input, error, setError, setInput,success };
}
with this simple hook i got all the fuctionality i need for a simple form , as it turns out the more generic a solution the worse it can be for edge cases.
the hook also takes in a generic type just like react hoo form to give you typesafety
- zustnad config
- notifications api files using zustand i managed global state and handled in coming notifications that , i listen to using pocketbase realtime endpoint
export function useRealTime() {
const queryClient = useQueryClient()
const alerts = useAlertStore()
useEffect(() => {
const unsubscribePromise = pb.realtime.subscribe('notifications', (data: RealTimeBNotificationRoot) => {
queryClient.invalidateQueries({queryKey:['notifications']})
alerts.increase(1)
if(alerts.has_read){
alerts.setLastAlert(data.record.created)
}})
return () => {
(async () => {
(await unsubscribePromise)();
})();
};
},[]);
}
It also persists the notification counts and clears them out when you navigate to the alerts route tov view them , unread comments also glow orange and auto mark themselves as read after a delay.
These notifications appear when the app was closed and activities happened in your abscence
this also involved a custom pocketbase endpoint to get the notification count and last alert
Click to expand code
package custom
import (
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
)
func CustomNotificationsRoute(app *pocketbase.PocketBase) echo.Route {
return echo.Route{
Method: http.MethodGet,
Path: "/custom_notifications",
Handler: func(c echo.Context) error {
var result struct {
Count int `db:"count" json:"count"`
Created string `bd:"created" json:"created"`
Details string `db:"details" json:"details"`
ID string `db:"id" json:"id"`
ItemID string `json:"item_id"`
Name string `db:"name" json:"name"`
Source string `db:"source" json:"source"`
Type string `db:"type" json:"type"`
Updated string `db:"updated" json:"updated"`
}
err := app.Dao().DB().NewQuery(`
SELECT COUNT(*) AS count, * FROM notifications WHERE created > {:created} ORDER BY created DESC LIMIT 1;
`).Bind(dbx.Params{
"created": c.QueryParam("created"),
}).One(&result)
if err != nil {
return apis.NewBadRequestError("Failed to count most recent notifications", err)
}
return c.JSON(200, result)
},
Middlewares: []echo.MiddlewareFunc{apis.ActivityLogger(app)},
Name: "",
}
}
-
custom hooks notable mentions :
- useAuthGuard This hook is used on the route layout level , to redirect to the login page if the user is not logged in. it also stores their initial destination in a query param that will be used to redirect them back to whre they were headed before
- useDebouncedvalues this hook is sued on the search bar input to delay the value change to avoid new network requests on every value changes -useScrollToTop this hook is used to scroll the view back to the top , this comes in handy in cases where one scrolls down a very long list and clicks on a link that takes them to another page with content spanning more than a the pages viewport, the browser will attempt to restore scroll position to the bottom on the page . this hook undoes that forcing the page to scroll to the top
- useDarkTheme this hook helps to toggle darkmode on and off by adding/removing the "dark" class to the documnet
- useScrollLock used in modals to prevent the background page the modal is on top of ftom scrolling while the modal is open
-
search bars for tasks and staff
-
custom error concatinations
expand code snippet
export const concatErrors = (err_res: any) => {
const errs = err_res?.data?.data;
// //no-console("errs === ",err_res?.data?.message)
if (errs && Object.keys(errs).length > 0) {
const err_key = Object.keys(errs);
// //no-console("errs keys",err_key)
let err_str = "";
err_key.forEach((key) => {
err_str +=
" - " + key + ":" + errs[key].message;
("");
});
return err_str;
}
if (err_res?.data?.message) {
return err_res?.data?.message;
}
if (err_res.message) return err_res.message;
return err_res;
};
Pocketbase is awesome in many ways but my personal favorites were
- The SDK supports generics on all the query/mutation methods
- The SDK also has a
send
method to query custom endpoints - The extensibility of the go package is very powerful with hooks that let you do everything form creaate custom
routes , query the logedin user to do backend validation and so server-side tranformations/validation - The best admin dashboard i've used in a while and the team is actively improving and polishing it
In modern React land we now have more options including server components and SSR streaming , but using the right tool for the job will become a valuable skill.
A Signup component was deliberately left out since the staff can ba manually entered using the admin dashboard for more securuty
Emails notifications and a chat syatem were on the raod map but those will be deffered for now