Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
H
hrsystem
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
hrsystem
Commits
524d1e1e
Commit
524d1e1e
authored
Apr 02, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 19 files via Son of Anton
parent
35640bca
Changes
19
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
2191 additions
and
9 deletions
+2191
-9
.env.example
backend/.env.example
+9
-8
.env.example
frontend/.env.example
+13
-1
page.tsx
frontend/src/app/(dashboard)/admin/templates/page.tsx
+386
-0
globals.css
frontend/src/app/globals.css
+68
-0
deduction-breakdown-chart.tsx
frontend/src/components/charts/deduction-breakdown-chart.tsx
+93
-0
payroll-summary-chart.tsx
frontend/src/components/charts/payroll-summary-chart.tsx
+77
-0
salary-trend-chart.tsx
frontend/src/components/charts/salary-trend-chart.tsx
+77
-0
task-completion-chart.tsx
frontend/src/components/charts/task-completion-chart.tsx
+51
-0
team-health-chart.tsx
frontend/src/components/charts/team-health-chart.tsx
+92
-0
file-upload.tsx
frontend/src/components/forms/file-upload.tsx
+175
-0
label-selector.tsx
frontend/src/components/forms/label-selector.tsx
+112
-0
priority-selector.tsx
frontend/src/components/forms/priority-selector.tsx
+70
-0
schedule-picker.tsx
frontend/src/components/forms/schedule-picker.tsx
+98
-0
user-selector.tsx
frontend/src/components/forms/user-selector.tsx
+155
-0
mobile-nav.tsx
frontend/src/components/layout/mobile-nav.tsx
+217
-0
error-boundary.tsx
frontend/src/components/shared/error-boundary.tsx
+65
-0
use-media-query.ts
frontend/src/hooks/use-media-query.ts
+43
-0
use-online-status.ts
frontend/src/hooks/use-online-status.ts
+131
-0
validators.ts
frontend/src/lib/validators.ts
+259
-0
No files found.
backend/.env.example
View file @
524d1e1e
# ============================
# ============================
================
# THE GRIND — Backend Environment Variables
# ============================
# ============================
================
# App
# App
lication
NODE_ENV=development
PORT=3001
FRONTEND_URL=http://localhost:3000
...
...
@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
#
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=CHANGE_
THIS_TO_A_REAL_SECRET_IN_PRODUCTION
JWT_SECRET=CHANGE_
ME_IN_PRODUCTION_OR_GET_HACKED_YOU_IDIOT
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY_DAYS=7
# MinIO
# MinIO
(S3-compatible storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
...
...
@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files
MAX_FILE_SIZE_BYTES=26214400
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
# Rate Limiting
# Session & Security
SESSION_TIMEOUT_HOURS=8
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
SESSION_TIMEOUT_HOURS=8
\ No newline at end of file
MAX_DAILY_LOGIN_ATTEMPTS=15
\ No newline at end of file
frontend/.env.example
View file @
524d1e1e
# ============================================
# THE GRIND — Frontend Environment Variables
# ============================================
# Backend API URL (internal, for SSR)
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Backend WebSocket URL (for Socket.io client)
NEXT_PUBLIC_WS_URL=http://localhost:3001
NEXT_PUBLIC_APP_NAME=The Grind
\ No newline at end of file
# Public URL of this frontend (used for generating links)
NEXT_PUBLIC_APP_URL=http://localhost:3000
# MinIO public URL (for serving uploaded files/images)
NEXT_PUBLIC_MINIO_URL=http://localhost:9000
\ No newline at end of file
frontend/src/app/(dashboard)/admin/templates/page.tsx
0 → 100644
View file @
524d1e1e
This diff is collapsed.
Click to expand it.
frontend/src/app/globals.css
View file @
524d1e1e
...
...
@@ -85,3 +85,71 @@
::-webkit-scrollbar-thumb:hover
{
@apply
bg-muted-foreground/30;
}
/* HUD Animations */
@keyframes
hud-pulse-red
{
0
%,
100
%
{
box-shadow
:
0
0
0
0
rgba
(
239
,
68
,
68
,
0
);
}
50
%
{
box-shadow
:
0
0
0
4px
rgba
(
239
,
68
,
68
,
0.3
);
}
}
@keyframes
hud-pulse-gold
{
0
%,
100
%
{
box-shadow
:
0
0
0
0
rgba
(
234
,
179
,
8
,
0
);
}
50
%
{
box-shadow
:
0
0
0
4px
rgba
(
234
,
179
,
8
,
0.3
);
}
}
@keyframes
fade-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
-4px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
slide-in-right
{
from
{
transform
:
translateX
(
100%
);
}
to
{
transform
:
translateX
(
0
);
}
}
@keyframes
count-up
{
from
{
opacity
:
0.5
;
transform
:
scale
(
0.95
);
}
to
{
opacity
:
1
;
transform
:
scale
(
1
);
}
}
.animate-hud-pulse-red
{
animation
:
hud-pulse-red
3s
ease-in-out
;
}
.animate-hud-pulse-gold
{
animation
:
hud-pulse-gold
3s
ease-in-out
;
}
.animate-fade-in
{
animation
:
fade-in
0.2s
ease-out
;
}
.animate-slide-in-right
{
animation
:
slide-in-right
0.3s
ease-out
;
}
.animate-count
{
animation
:
count-up
0.3s
ease-out
;
}
/* Safe area inset for mobile bottom bar */
.safe-area-inset-bottom
{
padding-bottom
:
env
(
safe-area-inset-bottom
,
0px
);
}
/* Touch-friendly drag area */
@media
(
pointer
:
coarse
)
{
.kanban-card
{
touch-action
:
none
;
-webkit-touch-callout
:
none
;
-webkit-user-select
:
none
;
user-select
:
none
;
}
}
/* Print styles */
@media
print
{
.no-print
{
display
:
none
!important
;
}
}
\ No newline at end of file
frontend/src/components/charts/deduction-breakdown-chart.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
interface
DeductionCategory
{
category
:
string
;
label
:
string
;
count
:
number
;
totalPiasters
:
number
;
color
:
string
;
}
interface
DeductionBreakdownChartProps
{
data
:
DeductionCategory
[];
className
?:
string
;
}
const
CATEGORY_COLORS
:
Record
<
string
,
string
>
=
{
A
:
'#EF4444'
,
B
:
'#F97316'
,
C
:
'#EAB308'
,
D
:
'#8B5CF6'
,
};
const
CATEGORY_LABELS
:
Record
<
string
,
string
>
=
{
A
:
'Deadline'
,
B
:
'Reporting'
,
C
:
'Quality'
,
D
:
'Communication'
,
};
export
function
DeductionBreakdownChart
({
data
,
className
}:
DeductionBreakdownChartProps
)
{
const
total
=
data
.
reduce
((
sum
,
d
)
=>
sum
+
d
.
totalPiasters
,
0
);
if
(
data
.
length
===
0
||
total
===
0
)
{
return
(
<
div
className=
{
cn
(
'text-center py-8 text-muted-foreground text-sm'
,
className
)
}
>
No deductions this period 🎉
</
div
>
);
}
return
(
<
div
className=
{
cn
(
'space-y-4'
,
className
)
}
>
{
/* Stacked bar */
}
<
div
className=
"h-6 rounded-full overflow-hidden flex"
>
{
data
.
map
((
cat
)
=>
{
const
width
=
(
cat
.
totalPiasters
/
total
)
*
100
;
if
(
width
===
0
)
return
null
;
return
(
<
div
key=
{
cat
.
category
}
className=
"h-full transition-all duration-500"
style=
{
{
width
:
`${width}%`
,
backgroundColor
:
cat
.
color
||
CATEGORY_COLORS
[
cat
.
category
]
||
'#6B7280'
}
}
title=
{
`${cat.label || CATEGORY_LABELS[cat.category]}: ${formatEgp(cat.totalPiasters)} (${cat.count})`
}
/>
);
})
}
</
div
>
{
/* Legend */
}
<
div
className=
"space-y-2"
>
{
data
.
map
((
cat
)
=>
{
const
percentage
=
total
>
0
?
Math
.
round
((
cat
.
totalPiasters
/
total
)
*
100
)
:
0
;
return
(
<
div
key=
{
cat
.
category
}
className=
"flex items-center justify-between text-sm"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"w-3 h-3 rounded-sm shrink-0"
style=
{
{
backgroundColor
:
cat
.
color
||
CATEGORY_COLORS
[
cat
.
category
]
||
'#6B7280'
}
}
/>
<
span
className=
"text-muted-foreground"
>
{
cat
.
label
||
CATEGORY_LABELS
[
cat
.
category
]
||
cat
.
category
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
cat
.
count
}
deduction
{
cat
.
count
!==
1
?
's'
:
''
}
</
span
>
<
span
className=
"font-mono font-medium text-red-500"
>
{
formatEgp
(
cat
.
totalPiasters
)
}
</
span
>
<
span
className=
"text-xs text-muted-foreground w-8 text-right"
>
{
percentage
}
%
</
span
>
</
div
>
</
div
>
);
})
}
</
div
>
{
/* Total */
}
<
div
className=
"border-t pt-2 flex items-center justify-between font-medium text-sm"
>
<
span
>
Total Deductions
</
span
>
<
span
className=
"text-red-500 font-mono"
>
{
formatEgp
(
total
)
}
</
span
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/charts/payroll-summary-chart.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
interface
PayrollSummaryData
{
month
:
string
;
gross
:
number
;
deductions
:
number
;
bounties
:
number
;
adjustments
:
number
;
net
:
number
;
contractorCount
:
number
;
}
interface
PayrollSummaryChartProps
{
data
:
PayrollSummaryData
[];
className
?:
string
;
}
export
function
PayrollSummaryChart
({
data
,
className
}:
PayrollSummaryChartProps
)
{
if
(
data
.
length
===
0
)
{
return
(
<
div
className=
{
cn
(
'text-center py-8 text-muted-foreground text-sm'
,
className
)
}
>
No payroll data available
</
div
>
);
}
const
maxNet
=
Math
.
max
(...
data
.
map
((
d
)
=>
d
.
net
));
return
(
<
div
className=
{
cn
(
'space-y-4'
,
className
)
}
>
{
/* Bar chart */
}
<
div
className=
"flex items-end gap-2 h-32"
>
{
data
.
map
((
point
,
i
)
=>
{
const
height
=
maxNet
>
0
?
(
point
.
net
/
maxNet
)
*
100
:
0
;
return
(
<
div
key=
{
i
}
className=
"flex-1 flex flex-col items-center gap-1 group relative"
>
<
div
className=
"w-full bg-primary/20 hover:bg-primary/30 rounded-t transition-all cursor-pointer"
style=
{
{
height
:
`${height}%`
,
minHeight
:
'4px'
}
}
/>
<
span
className=
"text-[9px] text-muted-foreground truncate w-full text-center"
>
{
point
.
month
}
</
span
>
{
/* Tooltip */
}
<
div
className=
"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10"
>
<
div
className=
"bg-popover border rounded-lg shadow-lg p-2 text-[10px] whitespace-nowrap space-y-0.5"
>
<
p
className=
"font-medium"
>
{
point
.
month
}
</
p
>
<
p
>
Gross:
{
formatEgp
(
point
.
gross
)
}
</
p
>
<
p
className=
"text-emerald-500"
>
Bounties: +
{
formatEgp
(
point
.
bounties
)
}
</
p
>
<
p
className=
"text-red-500"
>
Deductions: -
{
formatEgp
(
point
.
deductions
)
}
</
p
>
<
p
className=
"text-blue-500"
>
Adjustments:
{
formatEgp
(
point
.
adjustments
)
}
</
p
>
<
p
className=
"font-bold border-t pt-0.5 mt-0.5"
>
Net:
{
formatEgp
(
point
.
net
)
}
</
p
>
<
p
className=
"text-muted-foreground"
>
{
point
.
contractorCount
}
contractors
</
p
>
</
div
>
</
div
>
</
div
>
);
})
}
</
div
>
{
/* Summary table for latest month */
}
{
data
.
length
>
0
&&
(
<
div
className=
"bg-muted/30 rounded-lg p-3 space-y-1.5 text-xs"
>
<
div
className=
"flex justify-between"
>
<
span
className=
"text-muted-foreground"
>
Latest:
{
data
[
data
.
length
-
1
].
month
}
</
span
>
<
span
className=
"font-medium"
>
{
data
[
data
.
length
-
1
].
contractorCount
}
contractors
</
span
>
</
div
>
<
div
className=
"flex justify-between"
>
<
span
className=
"text-muted-foreground"
>
Net Payout
</
span
>
<
span
className=
"font-bold"
>
{
formatEgp
(
data
[
data
.
length
-
1
].
net
)
}
</
span
>
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/charts/salary-trend-chart.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useMemo
}
from
'react'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
interface
DataPoint
{
month
:
string
;
salary
:
number
;
deductions
:
number
;
bounties
:
number
;
net
:
number
;
}
interface
SalaryTrendChartProps
{
data
:
DataPoint
[];
className
?:
string
;
}
export
function
SalaryTrendChart
({
data
,
className
}:
SalaryTrendChartProps
)
{
const
maxValue
=
useMemo
(()
=>
{
if
(
data
.
length
===
0
)
return
100
;
return
Math
.
max
(...
data
.
map
((
d
)
=>
Math
.
max
(
d
.
salary
,
d
.
net
)))
*
1.1
;
},
[
data
]);
if
(
data
.
length
===
0
)
{
return
(
<
div
className=
{
`text-center py-8 text-muted-foreground text-sm ${className || ''}`
}
>
No salary data available
</
div
>
);
}
return
(
<
div
className=
{
className
}
>
<
div
className=
"flex items-end gap-1 h-40"
>
{
data
.
map
((
point
,
i
)
=>
{
const
salaryHeight
=
(
point
.
salary
/
maxValue
)
*
100
;
const
netHeight
=
(
point
.
net
/
maxValue
)
*
100
;
return
(
<
div
key=
{
i
}
className=
"flex-1 flex flex-col items-center gap-1 group relative"
>
<
div
className=
"w-full flex items-end gap-px h-32"
>
<
div
className=
"flex-1 bg-blue-500/20 rounded-t transition-all"
style=
{
{
height
:
`${salaryHeight}%`
}
}
title=
{
`Salary: ${formatEgp(point.salary)}`
}
/>
<
div
className=
"flex-1 bg-emerald-500/40 rounded-t transition-all"
style=
{
{
height
:
`${netHeight}%`
}
}
title=
{
`Net: ${formatEgp(point.net)}`
}
/>
</
div
>
<
span
className=
"text-[9px] text-muted-foreground truncate w-full text-center"
>
{
point
.
month
}
</
span
>
{
/* Tooltip on hover */
}
<
div
className=
"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10"
>
<
div
className=
"bg-popover border rounded-lg shadow-lg p-2 text-[10px] whitespace-nowrap space-y-0.5"
>
<
p
className=
"font-medium"
>
{
point
.
month
}
</
p
>
<
p
>
Salary:
{
formatEgp
(
point
.
salary
)
}
</
p
>
<
p
className=
"text-red-500"
>
Deductions: -
{
formatEgp
(
point
.
deductions
)
}
</
p
>
<
p
className=
"text-emerald-500"
>
Bounties: +
{
formatEgp
(
point
.
bounties
)
}
</
p
>
<
p
className=
"font-bold"
>
Net:
{
formatEgp
(
point
.
net
)
}
</
p
>
</
div
>
</
div
>
</
div
>
);
})
}
</
div
>
<
div
className=
"flex items-center gap-4 mt-3 text-[10px] text-muted-foreground justify-center"
>
<
span
className=
"flex items-center gap-1"
><
span
className=
"w-2 h-2 rounded bg-blue-500/40"
/>
Salary
</
span
>
<
span
className=
"flex items-center gap-1"
><
span
className=
"w-2 h-2 rounded bg-emerald-500/40"
/>
Net
</
span
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/charts/task-completion-chart.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
TaskCompletionData
{
label
:
string
;
completed
:
number
;
total
:
number
;
color
?:
string
;
}
interface
TaskCompletionChartProps
{
data
:
TaskCompletionData
[];
className
?:
string
;
}
export
function
TaskCompletionChart
({
data
,
className
}:
TaskCompletionChartProps
)
{
if
(
data
.
length
===
0
)
{
return
(
<
div
className=
{
cn
(
'text-center py-8 text-muted-foreground text-sm'
,
className
)
}
>
No task data available
</
div
>
);
}
return
(
<
div
className=
{
cn
(
'space-y-3'
,
className
)
}
>
{
data
.
map
((
item
,
i
)
=>
{
const
percentage
=
item
.
total
>
0
?
Math
.
round
((
item
.
completed
/
item
.
total
)
*
100
)
:
0
;
const
barColor
=
item
.
color
||
(
percentage
>=
80
?
'bg-emerald-500'
:
percentage
>=
50
?
'bg-yellow-500'
:
'bg-red-500'
);
return
(
<
div
key=
{
i
}
>
<
div
className=
"flex items-center justify-between mb-1"
>
<
span
className=
"text-sm font-medium"
>
{
item
.
label
}
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
{
item
.
completed
}
/
{
item
.
total
}
(
{
percentage
}
%)
</
span
>
</
div
>
<
div
className=
"h-2 bg-muted rounded-full overflow-hidden"
>
<
div
className=
{
cn
(
'h-full rounded-full transition-all duration-500'
,
barColor
)
}
style=
{
{
width
:
`${percentage}%`
}
}
/>
</
div
>
</
div
>
);
})
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/charts/team-health-chart.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
Shield
,
AlertTriangle
,
Skull
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
TeamMember
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
avatar
?:
string
;
health
:
'HEALTHY'
|
'WARNING'
|
'CRITICAL'
;
deductionCount
:
number
;
retentionPercent
:
number
;
currentStreak
:
number
;
}
interface
TeamHealthChartProps
{
members
:
TeamMember
[];
className
?:
string
;
}
const
healthConfig
=
{
HEALTHY
:
{
icon
:
Shield
,
color
:
'text-emerald-500'
,
bg
:
'bg-emerald-500/10'
,
label
:
'Healthy'
},
WARNING
:
{
icon
:
AlertTriangle
,
color
:
'text-yellow-500'
,
bg
:
'bg-yellow-500/10'
,
label
:
'Warning'
},
CRITICAL
:
{
icon
:
Skull
,
color
:
'text-red-500'
,
bg
:
'bg-red-500/10'
,
label
:
'Critical'
},
};
export
function
TeamHealthChart
({
members
,
className
}:
TeamHealthChartProps
)
{
if
(
members
.
length
===
0
)
{
return
(
<
div
className=
{
cn
(
'text-center py-8 text-muted-foreground text-sm'
,
className
)
}
>
No team members
</
div
>
);
}
const
healthyCount
=
members
.
filter
((
m
)
=>
m
.
health
===
'HEALTHY'
).
length
;
const
warningCount
=
members
.
filter
((
m
)
=>
m
.
health
===
'WARNING'
).
length
;
const
criticalCount
=
members
.
filter
((
m
)
=>
m
.
health
===
'CRITICAL'
).
length
;
return
(
<
div
className=
{
cn
(
'space-y-4'
,
className
)
}
>
{
/* Summary */
}
<
div
className=
"flex gap-3"
>
<
div
className=
"flex items-center gap-1.5 text-xs"
>
<
Shield
size=
{
12
}
className=
"text-emerald-500"
/>
<
span
>
{
healthyCount
}
Healthy
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1.5 text-xs"
>
<
AlertTriangle
size=
{
12
}
className=
"text-yellow-500"
/>
<
span
>
{
warningCount
}
Warning
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1.5 text-xs"
>
<
Skull
size=
{
12
}
className=
"text-red-500"
/>
<
span
>
{
criticalCount
}
Critical
</
span
>
</
div
>
</
div
>
{
/* Member list */
}
<
div
className=
"space-y-2"
>
{
members
.
map
((
member
)
=>
{
const
config
=
healthConfig
[
member
.
health
];
const
Icon
=
config
.
icon
;
return
(
<
div
key=
{
member
.
id
}
className=
"flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<
UserAvatar
firstName=
{
member
.
firstName
}
lastName=
{
member
.
lastName
}
avatar=
{
member
.
avatar
}
size=
"sm"
/>
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
"text-sm font-medium truncate"
>
{
member
.
firstName
}
{
member
.
lastName
}
</
p
>
<
div
className=
"flex items-center gap-2 text-[10px] text-muted-foreground"
>
<
span
>
🔥
{
member
.
currentStreak
}
d streak
</
span
>
<
span
>
·
</
span
>
<
span
>
{
member
.
deductionCount
}
deductions
</
span
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"w-16 h-1.5 bg-muted rounded-full overflow-hidden"
>
<
div
className=
{
cn
(
'h-full rounded-full'
,
member
.
retentionPercent
>=
80
?
'bg-emerald-500'
:
member
.
retentionPercent
>=
60
?
'bg-yellow-500'
:
'bg-red-500'
)
}
style=
{
{
width
:
`${Math.min(100, member.retentionPercent)}%`
}
}
/>
</
div
>
<
span
className=
{
cn
(
'text-xs'
,
config
.
color
)
}
>
{
member
.
retentionPercent
}
%
</
span
>
<
Icon
size=
{
14
}
className=
{
config
.
color
}
/>
</
div
>
</
div
>
);
})
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/forms/file-upload.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
,
useCallback
}
from
'react'
;
import
{
Upload
,
X
,
FileIcon
,
Image
as
ImageIcon
,
Loader2
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
toast
}
from
'sonner'
;
interface
FileUploadProps
{
onUpload
:
(
file
:
File
)
=>
Promise
<
void
>
;
accept
?:
string
;
maxSizeMB
?:
number
;
multiple
?:
boolean
;
className
?:
string
;
label
?:
string
;
disabled
?:
boolean
;
}
export
function
FileUpload
({
onUpload
,
accept
,
maxSizeMB
=
25
,
multiple
=
false
,
className
,
label
=
'Drop files here or click to upload'
,
disabled
=
false
,
}:
FileUploadProps
)
{
const
[
isDragging
,
setIsDragging
]
=
useState
(
false
);
const
[
isUploading
,
setIsUploading
]
=
useState
(
false
);
const
[
uploadQueue
,
setUploadQueue
]
=
useState
<
File
[]
>
([]);
const
validateFile
=
(
file
:
File
):
boolean
=>
{
if
(
file
.
size
>
maxSizeMB
*
1024
*
1024
)
{
toast
.
error
(
`File "
${
file
.
name
}
" exceeds
${
maxSizeMB
}
MB limit`
);
return
false
;
}
if
(
accept
)
{
const
acceptedTypes
=
accept
.
split
(
','
).
map
((
t
)
=>
t
.
trim
());
const
isAccepted
=
acceptedTypes
.
some
((
type
)
=>
{
if
(
type
.
startsWith
(
'.'
))
{
return
file
.
name
.
toLowerCase
().
endsWith
(
type
.
toLowerCase
());
}
if
(
type
.
endsWith
(
'/*'
))
{
return
file
.
type
.
startsWith
(
type
.
replace
(
'/*'
,
'/'
));
}
return
file
.
type
===
type
;
});
if
(
!
isAccepted
)
{
toast
.
error
(
`File type "
${
file
.
type
}
" is not accepted`
);
return
false
;
}
}
return
true
;
};
const
handleFiles
=
useCallback
(
async
(
files
:
FileList
|
File
[])
=>
{
const
validFiles
=
Array
.
from
(
files
).
filter
(
validateFile
);
if
(
validFiles
.
length
===
0
)
return
;
setIsUploading
(
true
);
setUploadQueue
(
validFiles
);
for
(
const
file
of
validFiles
)
{
try
{
await
onUpload
(
file
);
}
catch
(
err
:
any
)
{
toast
.
error
(
`Failed to upload "
${
file
.
name
}
":
${
err
.
message
||
'Unknown error'
}
`
);
}
}
setIsUploading
(
false
);
setUploadQueue
([]);
},
[
onUpload
,
accept
,
maxSizeMB
],
);
const
handleDrop
=
useCallback
(
(
e
:
React
.
DragEvent
)
=>
{
e
.
preventDefault
();
setIsDragging
(
false
);
if
(
disabled
||
isUploading
)
return
;
handleFiles
(
e
.
dataTransfer
.
files
);
},
[
handleFiles
,
disabled
,
isUploading
],
);
const
handleInputChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
e
.
target
.
files
)
{
handleFiles
(
e
.
target
.
files
);
e
.
target
.
value
=
''
;
}
};
return
(
<
div
className=
{
cn
(
'relative border-2 border-dashed rounded-xl p-6 text-center transition-colors cursor-pointer'
,
isDragging
?
'border-primary bg-primary/5'
:
'border-border hover:border-primary/50'
,
disabled
&&
'opacity-50 cursor-not-allowed'
,
isUploading
&&
'pointer-events-none'
,
className
,
)
}
onDragOver=
{
(
e
)
=>
{
e
.
preventDefault
();
if
(
!
disabled
)
setIsDragging
(
true
);
}
}
onDragLeave=
{
()
=>
setIsDragging
(
false
)
}
onDrop=
{
handleDrop
}
onClick=
{
()
=>
{
if
(
!
disabled
&&
!
isUploading
)
{
document
.
getElementById
(
'file-upload-input'
)?.
click
();
}
}
}
>
<
input
id=
"file-upload-input"
type=
"file"
accept=
{
accept
}
multiple=
{
multiple
}
onChange=
{
handleInputChange
}
className=
"hidden"
disabled=
{
disabled
||
isUploading
}
/>
{
isUploading
?
(
<
div
className=
"space-y-2"
>
<
Loader2
size=
{
32
}
className=
"mx-auto animate-spin text-primary"
/>
<
p
className=
"text-sm text-muted-foreground"
>
Uploading
{
uploadQueue
.
length
}
file
{
uploadQueue
.
length
>
1
?
's'
:
''
}
...
</
p
>
</
div
>
)
:
(
<
div
className=
"space-y-2"
>
<
Upload
size=
{
32
}
className=
"mx-auto text-muted-foreground"
/>
<
p
className=
"text-sm text-muted-foreground"
>
{
label
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
Max
{
maxSizeMB
}
MB per file
</
p
>
</
div
>
)
}
</
div
>
);
}
interface
FilePreviewProps
{
name
:
string
;
size
:
number
;
url
?:
string
;
mimeType
?:
string
;
onRemove
?:
()
=>
void
;
}
export
function
FilePreview
({
name
,
size
,
url
,
mimeType
,
onRemove
}:
FilePreviewProps
)
{
const
isImage
=
mimeType
?.
startsWith
(
'image/'
);
const
sizeStr
=
size
>
1048576
?
`
${(
size
/
1048576
).
toFixed
(
1
)}
MB`
:
`
${
Math
.
round
(
size
/
1024
)}
KB`
;
return
(
<
div
className=
"flex items-center gap-3 p-2 rounded-lg border bg-muted/30 group"
>
{
isImage
&&
url
?
(
<
img
src=
{
url
}
alt=
{
name
}
className=
"w-10 h-10 rounded object-cover"
/>
)
:
(
<
div
className=
"w-10 h-10 rounded bg-muted flex items-center justify-center"
>
<
FileIcon
size=
{
18
}
className=
"text-muted-foreground"
/>
</
div
>
)
}
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
"text-sm truncate"
>
{
name
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
{
sizeStr
}
</
p
>
</
div
>
{
onRemove
&&
(
<
button
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
onRemove
();
}
}
className=
"p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<
X
size=
{
14
}
/>
</
button
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/forms/label-selector.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
Check
,
X
,
Plus
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
Label
{
id
:
string
;
name
:
string
;
color
:
string
;
}
interface
LabelSelectorProps
{
value
:
string
[];
onChange
:
(
ids
:
string
[])
=>
void
;
boardId
?:
string
;
className
?:
string
;
}
export
function
LabelSelector
({
value
,
onChange
,
boardId
,
className
}:
LabelSelectorProps
)
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
[
labels
,
setLabels
]
=
useState
<
Label
[]
>
([]);
useEffect
(()
=>
{
loadLabels
();
},
[
boardId
]);
const
loadLabels
=
async
()
=>
{
try
{
const
endpoints
=
[
apiGet
(
'/labels'
,
{
limit
:
100
})];
if
(
boardId
)
endpoints
.
push
(
apiGet
(
`/boards/
${
boardId
}
/labels`
,
{
limit
:
100
}));
const
results
=
await
Promise
.
all
(
endpoints
);
const
allLabels
=
results
.
flatMap
((
r
)
=>
r
.
data
||
[]);
const
unique
=
allLabels
.
filter
(
(
label
,
index
,
arr
)
=>
arr
.
findIndex
((
l
)
=>
l
.
id
===
label
.
id
)
===
index
,
);
setLabels
(
unique
);
}
catch
{
/* fail silently */
}
};
const
toggleLabel
=
(
labelId
:
string
)
=>
{
if
(
value
.
includes
(
labelId
))
{
onChange
(
value
.
filter
((
id
)
=>
id
!==
labelId
));
}
else
{
onChange
([...
value
,
labelId
]);
}
};
const
selectedLabels
=
labels
.
filter
((
l
)
=>
value
.
includes
(
l
.
id
));
return
(
<
div
className=
{
cn
(
'relative'
,
className
)
}
>
{
/* Selected labels */
}
<
div
className=
"flex flex-wrap gap-1 mb-1"
>
{
selectedLabels
.
map
((
label
)
=>
(
<
span
key=
{
label
.
id
}
className=
"inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style=
{
{
backgroundColor
:
`${label.color}20`
,
color
:
label
.
color
}
}
>
{
label
.
name
}
<
button
onClick=
{
()
=>
toggleLabel
(
label
.
id
)
}
>
<
X
size=
{
10
}
/>
</
button
>
</
span
>
))
}
<
button
onClick=
{
()
=>
setIsOpen
(
!
isOpen
)
}
className=
"inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs text-muted-foreground hover:bg-accent border border-dashed"
>
<
Plus
size=
{
10
}
/>
Label
</
button
>
</
div
>
{
/* Dropdown */
}
{
isOpen
&&
(
<>
<
div
className=
"fixed inset-0 z-10"
onClick=
{
()
=>
setIsOpen
(
false
)
}
/>
<
div
className=
"absolute z-20 mt-1 w-full bg-card rounded-lg border shadow-lg max-h-48 overflow-y-auto p-1"
>
{
labels
.
length
===
0
?
(
<
p
className=
"p-2 text-xs text-muted-foreground text-center"
>
No labels available
</
p
>
)
:
(
labels
.
map
((
label
)
=>
{
const
isSelected
=
value
.
includes
(
label
.
id
);
return
(
<
button
key=
{
label
.
id
}
onClick=
{
()
=>
toggleLabel
(
label
.
id
)
}
className=
{
cn
(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-accent transition-colors'
,
isSelected
&&
'bg-accent/50'
,
)
}
>
<
span
className=
"w-3 h-3 rounded-sm shrink-0"
style=
{
{
backgroundColor
:
label
.
color
}
}
/>
<
span
className=
"flex-1"
>
{
label
.
name
}
</
span
>
{
isSelected
&&
<
Check
size=
{
12
}
className=
"text-primary"
/>
}
</
button
>
);
})
)
}
</
div
>
</>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/forms/priority-selector.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
cn
}
from
'@/lib/utils'
;
const
PRIORITIES
=
[
{
value
:
'CRITICAL'
,
label
:
'Critical'
,
color
:
'bg-red-500'
,
textColor
:
'text-red-500'
,
emoji
:
'🔴'
},
{
value
:
'HIGH'
,
label
:
'High'
,
color
:
'bg-orange-500'
,
textColor
:
'text-orange-500'
,
emoji
:
'🟠'
},
{
value
:
'MEDIUM'
,
label
:
'Medium'
,
color
:
'bg-yellow-500'
,
textColor
:
'text-yellow-500'
,
emoji
:
'🟡'
},
{
value
:
'LOW'
,
label
:
'Low'
,
color
:
'bg-green-500'
,
textColor
:
'text-green-500'
,
emoji
:
'🟢'
},
{
value
:
'NONE'
,
label
:
'None'
,
color
:
'bg-gray-300'
,
textColor
:
'text-muted-foreground'
,
emoji
:
'⚪'
},
]
as
const
;
interface
PrioritySelectorProps
{
value
:
string
;
onChange
:
(
value
:
string
)
=>
void
;
variant
?:
'dropdown'
|
'buttons'
;
className
?:
string
;
}
export
function
PrioritySelector
({
value
,
onChange
,
variant
=
'dropdown'
,
className
}:
PrioritySelectorProps
)
{
if
(
variant
===
'buttons'
)
{
return
(
<
div
className=
{
cn
(
'flex gap-1'
,
className
)
}
>
{
PRIORITIES
.
map
((
p
)
=>
(
<
button
key=
{
p
.
value
}
onClick=
{
()
=>
onChange
(
p
.
value
)
}
className=
{
cn
(
'flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-colors'
,
value
===
p
.
value
?
`${p.textColor} bg-current/10 border-current/20 font-medium`
:
'text-muted-foreground hover:bg-accent'
,
)
}
>
<
span
>
{
p
.
emoji
}
</
span
>
<
span
>
{
p
.
label
}
</
span
>
</
button
>
))
}
</
div
>
);
}
const
selected
=
PRIORITIES
.
find
((
p
)
=>
p
.
value
===
value
)
||
PRIORITIES
[
4
];
return
(
<
select
value=
{
value
}
onChange=
{
(
e
)
=>
onChange
(
e
.
target
.
value
)
}
className=
{
cn
(
'px-3 py-2 rounded-lg border bg-background text-sm'
,
className
)
}
>
{
PRIORITIES
.
map
((
p
)
=>
(
<
option
key=
{
p
.
value
}
value=
{
p
.
value
}
>
{
p
.
emoji
}
{
p
.
label
}
</
option
>
))
}
</
select
>
);
}
export
function
PriorityBadge
({
priority
}:
{
priority
:
string
})
{
const
p
=
PRIORITIES
.
find
((
pr
)
=>
pr
.
value
===
priority
);
if
(
!
p
||
p
.
value
===
'NONE'
)
return
null
;
return
(
<
span
className=
{
cn
(
'inline-flex items-center gap-0.5 text-[10px] font-medium'
,
p
.
textColor
)
}
>
<
span
className=
{
cn
(
'w-1.5 h-1.5 rounded-full'
,
p
.
color
)
}
/>
{
p
.
label
}
</
span
>
);
}
\ No newline at end of file
frontend/src/components/forms/schedule-picker.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
Building
,
Home
,
X
as
XIcon
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
const
DAY_NAMES
=
[
'sunday'
,
'monday'
,
'tuesday'
,
'wednesday'
,
'thursday'
];
const
DAY_LABELS
=
[
'Sunday'
,
'Monday'
,
'Tuesday'
,
'Wednesday'
,
'Thursday'
];
const
DAY_OPTIONS
=
[
{
value
:
'IN_OFFICE'
,
label
:
'Office'
,
icon
:
Building
,
emoji
:
'🏢'
,
color
:
'bg-blue-500/10 text-blue-600 border-blue-500/20'
},
{
value
:
'REMOTE'
,
label
:
'Remote'
,
icon
:
Home
,
emoji
:
'🏠'
,
color
:
'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
},
{
value
:
'OFF'
,
label
:
'Off'
,
icon
:
XIcon
,
emoji
:
'❌'
,
color
:
'bg-muted text-muted-foreground border-border'
},
];
interface
SchedulePickerProps
{
value
:
Record
<
string
,
string
>
;
onChange
:
(
schedule
:
Record
<
string
,
string
>
)
=>
void
;
showSalaryPreview
?:
boolean
;
contractorType
?:
'FULL_TIME'
|
'INTERN'
;
className
?:
string
;
disabled
?:
boolean
;
}
export
function
SchedulePicker
({
value
,
onChange
,
showSalaryPreview
=
false
,
contractorType
=
'FULL_TIME'
,
className
,
disabled
=
false
,
}:
SchedulePickerProps
)
{
const
setDay
=
(
day
:
string
,
type
:
string
)
=>
{
if
(
disabled
)
return
;
onChange
({
...
value
,
[
day
]:
type
});
};
const
calculateBaseSalary
=
():
number
=>
{
const
isFullTime
=
contractorType
===
'FULL_TIME'
;
let
total
=
0
;
for
(
const
[,
type
]
of
Object
.
entries
(
value
))
{
if
(
type
===
'IN_OFFICE'
)
total
+=
isFullTime
?
240000
:
100000
;
else
if
(
type
===
'REMOTE'
)
total
+=
isFullTime
?
160000
:
50000
;
}
return
total
;
};
const
workingDays
=
Object
.
values
(
value
).
filter
((
v
)
=>
v
!==
'OFF'
).
length
;
const
inOfficeDays
=
Object
.
values
(
value
).
filter
((
v
)
=>
v
===
'IN_OFFICE'
).
length
;
const
remoteDays
=
Object
.
values
(
value
).
filter
((
v
)
=>
v
===
'REMOTE'
).
length
;
const
baseSalary
=
calculateBaseSalary
();
return
(
<
div
className=
{
cn
(
'space-y-3'
,
className
)
}
>
{
DAY_NAMES
.
map
((
day
,
i
)
=>
(
<
div
key=
{
day
}
className=
"flex items-center justify-between p-3 rounded-lg border"
>
<
span
className=
"text-sm font-medium w-28"
>
{
DAY_LABELS
[
i
]
}
</
span
>
<
div
className=
"flex gap-1.5"
>
{
DAY_OPTIONS
.
map
((
opt
)
=>
{
const
isSelected
=
value
[
day
]
===
opt
.
value
;
return
(
<
button
key=
{
opt
.
value
}
type=
"button"
onClick=
{
()
=>
setDay
(
day
,
opt
.
value
)
}
disabled=
{
disabled
}
className=
{
cn
(
'flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors'
,
isSelected
?
`${opt.color} ring-1 ring-primary/30`
:
'hover:bg-accent/50'
,
disabled
&&
'opacity-50 cursor-not-allowed'
,
)
}
>
<
span
>
{
opt
.
emoji
}
</
span
>
<
span
>
{
opt
.
label
}
</
span
>
</
button
>
);
})
}
</
div
>
</
div
>
))
}
{
showSalaryPreview
&&
(
<
div
className=
"bg-accent rounded-xl p-4 space-y-2"
>
<
p
className=
"text-xs text-muted-foreground uppercase tracking-wider"
>
Base Monthly Salary
</
p
>
<
p
className=
"text-3xl font-bold"
>
EGP
{
(
baseSalary
/
100
).
toLocaleString
()
}
</
p
>
<
div
className=
"flex items-center gap-3 text-xs text-muted-foreground"
>
<
span
>
{
workingDays
}
working days/week
</
span
>
<
span
>
·
</
span
>
<
span
>
{
inOfficeDays
}
in-office
</
span
>
<
span
>
·
</
span
>
<
span
>
{
remoteDays
}
remote
</
span
>
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/forms/user-selector.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
Search
,
X
,
Check
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
useDebounce
}
from
'@/hooks/use-debounce'
;
interface
UserSelectorProps
{
value
:
string
[];
onChange
:
(
ids
:
string
[])
=>
void
;
boardId
?:
string
;
roleFilter
?:
string
;
placeholder
?:
string
;
maxSelections
?:
number
;
className
?:
string
;
}
export
function
UserSelector
({
value
,
onChange
,
boardId
,
roleFilter
,
placeholder
=
'Search users...'
,
maxSelections
,
className
,
}:
UserSelectorProps
)
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
users
,
setUsers
]
=
useState
<
any
[]
>
([]);
const
[
selectedUsers
,
setSelectedUsers
]
=
useState
<
any
[]
>
([]);
const
debouncedSearch
=
useDebounce
(
search
,
300
);
useEffect
(()
=>
{
loadUsers
();
},
[
debouncedSearch
,
boardId
,
roleFilter
]);
useEffect
(()
=>
{
if
(
value
.
length
>
0
&&
selectedUsers
.
length
===
0
)
{
loadSelectedUsers
();
}
},
[
value
]);
const
loadUsers
=
async
()
=>
{
try
{
const
params
:
Record
<
string
,
any
>
=
{
limit
:
20
,
status
:
'ACTIVE'
};
if
(
debouncedSearch
)
params
.
search
=
debouncedSearch
;
if
(
roleFilter
)
params
.
role
=
roleFilter
;
const
res
=
await
apiGet
(
'/users'
,
params
);
setUsers
(
res
.
data
||
[]);
}
catch
{
/* fail silently */
}
};
const
loadSelectedUsers
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/users'
,
{
limit
:
100
,
status
:
'ACTIVE'
});
const
allUsers
=
res
.
data
||
[];
setSelectedUsers
(
allUsers
.
filter
((
u
:
any
)
=>
value
.
includes
(
u
.
id
)));
}
catch
{
/* fail silently */
}
};
const
toggleUser
=
useCallback
(
(
user
:
any
)
=>
{
const
isSelected
=
value
.
includes
(
user
.
id
);
if
(
isSelected
)
{
const
next
=
value
.
filter
((
id
)
=>
id
!==
user
.
id
);
onChange
(
next
);
setSelectedUsers
((
prev
)
=>
prev
.
filter
((
u
)
=>
u
.
id
!==
user
.
id
));
}
else
{
if
(
maxSelections
&&
value
.
length
>=
maxSelections
)
return
;
onChange
([...
value
,
user
.
id
]);
setSelectedUsers
((
prev
)
=>
[...
prev
,
user
]);
}
},
[
value
,
onChange
,
maxSelections
],
);
const
removeUser
=
(
userId
:
string
)
=>
{
onChange
(
value
.
filter
((
id
)
=>
id
!==
userId
));
setSelectedUsers
((
prev
)
=>
prev
.
filter
((
u
)
=>
u
.
id
!==
userId
));
};
return
(
<
div
className=
{
cn
(
'relative'
,
className
)
}
>
{
/* Selected chips */
}
{
selectedUsers
.
length
>
0
&&
(
<
div
className=
"flex flex-wrap gap-1 mb-2"
>
{
selectedUsers
.
map
((
user
)
=>
(
<
span
key=
{
user
.
id
}
className=
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-accent text-xs"
>
<
UserAvatar
firstName=
{
user
.
firstName
}
lastName=
{
user
.
lastName
}
avatar=
{
user
.
avatar
}
size=
"xs"
/>
<
span
>
{
user
.
firstName
}
{
user
.
lastName
}
</
span
>
<
button
onClick=
{
()
=>
removeUser
(
user
.
id
)
}
className=
"hover:text-destructive"
>
<
X
size=
{
12
}
/>
</
button
>
</
span
>
))
}
</
div
>
)
}
{
/* Search input */
}
<
div
className=
"relative"
>
<
Search
size=
{
14
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
value=
{
search
}
onChange=
{
(
e
)
=>
setSearch
(
e
.
target
.
value
)
}
onFocus=
{
()
=>
setIsOpen
(
true
)
}
placeholder=
{
placeholder
}
className=
"w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
{
/* Dropdown */
}
{
isOpen
&&
(
<>
<
div
className=
"fixed inset-0 z-10"
onClick=
{
()
=>
setIsOpen
(
false
)
}
/>
<
div
className=
"absolute z-20 mt-1 w-full bg-card rounded-lg border shadow-lg max-h-48 overflow-y-auto"
>
{
users
.
length
===
0
?
(
<
p
className=
"p-3 text-sm text-muted-foreground text-center"
>
No users found
</
p
>
)
:
(
users
.
map
((
user
)
=>
{
const
isSelected
=
value
.
includes
(
user
.
id
);
return
(
<
button
key=
{
user
.
id
}
onClick=
{
()
=>
toggleUser
(
user
)
}
className=
{
cn
(
'w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-accent transition-colors text-sm'
,
isSelected
&&
'bg-accent/50'
,
)
}
>
<
UserAvatar
firstName=
{
user
.
firstName
}
lastName=
{
user
.
lastName
}
avatar=
{
user
.
avatar
}
size=
"xs"
/>
<
div
className=
"flex-1 min-w-0"
>
<
span
className=
"truncate"
>
{
user
.
firstName
}
{
user
.
lastName
}
</
span
>
<
span
className=
"text-xs text-muted-foreground ml-1"
>
@
{
user
.
username
}
</
span
>
</
div
>
{
isSelected
&&
<
Check
size=
{
14
}
className=
"text-primary shrink-0"
/>
}
</
button
>
);
})
)
}
</
div
>
</>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/layout/mobile-nav.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
}
from
'react'
;
import
{
usePathname
}
from
'next/navigation'
;
import
Link
from
'next/link'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
useIsMobile
}
from
'@/hooks/use-media-query'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Menu
,
X
,
LayoutDashboard
,
Kanban
,
ListTodo
,
FileText
,
Wallet
,
MessageSquare
,
Bell
,
Star
,
Clock
,
Calendar
,
Users
,
Settings
,
Shield
,
BarChart3
,
UserCog
,
Send
,
AlertTriangle
,
DollarSign
,
BookOpen
,
GraduationCap
,
}
from
'lucide-react'
;
interface
MobileNavItem
{
label
:
string
;
href
:
string
;
icon
:
React
.
ElementType
;
roles
?:
string
[];
badge
?:
number
;
}
const
NAV_ITEMS
:
MobileNavItem
[]
=
[
{
label
:
'Dashboard'
,
href
:
'/'
,
icon
:
LayoutDashboard
},
{
label
:
'Boards'
,
href
:
'/boards'
,
icon
:
Kanban
},
{
label
:
'My Tasks'
,
href
:
'/my-tasks'
,
icon
:
ListTodo
},
{
label
:
'Reports'
,
href
:
'/reports'
,
icon
:
FileText
},
{
label
:
'Salary'
,
href
:
'/salary'
,
icon
:
Wallet
},
{
label
:
'Messages'
,
href
:
'/messages'
,
icon
:
MessageSquare
},
{
label
:
'Notifications'
,
href
:
'/notifications'
,
icon
:
Bell
},
{
label
:
'Evaluations'
,
href
:
'/evaluations'
,
icon
:
Star
},
{
label
:
'Learning'
,
href
:
'/learning'
,
icon
:
GraduationCap
},
{
label
:
'Schedule'
,
href
:
'/schedule'
,
icon
:
Clock
},
{
label
:
'Meetings'
,
href
:
'/meetings'
,
icon
:
Calendar
},
{
label
:
'Directory'
,
href
:
'/directory'
,
icon
:
Users
},
];
const
ADMIN_ITEMS
:
MobileNavItem
[]
=
[
{
label
:
'Contractors'
,
href
:
'/admin/contractors'
,
icon
:
UserCog
,
roles
:
[
'SUPER_ADMIN'
,
'ADMIN'
]
},
{
label
:
'Deductions'
,
href
:
'/admin/deductions'
,
icon
:
AlertTriangle
,
roles
:
[
'SUPER_ADMIN'
,
'ADMIN'
]
},
{
label
:
'Payroll'
,
href
:
'/admin/payroll'
,
icon
:
DollarSign
,
roles
:
[
'SUPER_ADMIN'
,
'ADMIN'
]
},
{
label
:
'Analytics'
,
href
:
'/admin/analytics'
,
icon
:
BarChart3
,
roles
:
[
'SUPER_ADMIN'
,
'ADMIN'
]
},
{
label
:
'Settings'
,
href
:
'/admin/settings'
,
icon
:
Settings
,
roles
:
[
'SUPER_ADMIN'
]
},
];
export
function
MobileNav
()
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
pathname
=
usePathname
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
{
unreadCount
}
=
useNotificationStore
();
const
isMobile
=
useIsMobile
();
if
(
!
isMobile
)
return
null
;
const
userRole
=
user
?.
role
||
'CONTRACTOR'
;
const
visibleAdminItems
=
ADMIN_ITEMS
.
filter
(
(
item
)
=>
!
item
.
roles
||
item
.
roles
.
includes
(
userRole
),
);
return
(
<>
{
/* Hamburger button */
}
<
button
onClick=
{
()
=>
setIsOpen
(
true
)
}
className=
"fixed top-3 left-3 z-50 p-2 rounded-lg bg-card border shadow-sm md:hidden"
aria
-
label=
"Open navigation"
>
<
Menu
size=
{
20
}
/>
</
button
>
{
/* Overlay */
}
{
isOpen
&&
(
<
div
className=
"fixed inset-0 z-[60] bg-black/50 md:hidden"
onClick=
{
()
=>
setIsOpen
(
false
)
}
/>
)
}
{
/* Slide-out panel */
}
<
div
className=
{
cn
(
'fixed top-0 left-0 z-[70] h-full w-72 bg-card border-r shadow-xl transform transition-transform duration-300 md:hidden'
,
isOpen
?
'translate-x-0'
:
'-translate-x-full'
,
)
}
>
{
/* Header */
}
<
div
className=
"h-14 flex items-center justify-between px-4 border-b"
>
<
span
className=
"font-black text-lg tracking-tighter"
>
THE GRIND
</
span
>
<
button
onClick=
{
()
=>
setIsOpen
(
false
)
}
className=
"p-1.5 rounded-md hover:bg-accent"
>
<
X
size=
{
18
}
/>
</
button
>
</
div
>
{
/* Nav items */
}
<
nav
className=
"flex-1 overflow-y-auto py-3 px-2"
>
<
div
className=
"space-y-0.5"
>
{
NAV_ITEMS
.
map
((
item
)
=>
{
const
isActive
=
pathname
===
item
.
href
||
(
item
.
href
!==
'/'
&&
pathname
.
startsWith
(
item
.
href
));
const
Icon
=
item
.
icon
;
const
showBadge
=
item
.
href
===
'/notifications'
&&
unreadCount
>
0
;
return
(
<
Link
key=
{
item
.
href
}
href=
{
item
.
href
}
onClick=
{
()
=>
setIsOpen
(
false
)
}
className=
{
cn
(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors relative'
,
isActive
?
'bg-accent font-medium'
:
'hover:bg-accent/50 text-muted-foreground'
,
)
}
>
<
Icon
size=
{
18
}
/>
<
span
>
{
item
.
label
}
</
span
>
{
showBadge
&&
(
<
span
className=
"absolute right-3 min-w-[18px] h-[18px] bg-destructive text-destructive-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1"
>
{
unreadCount
>
99
?
'99+'
:
unreadCount
}
</
span
>
)
}
</
Link
>
);
})
}
</
div
>
{
visibleAdminItems
.
length
>
0
&&
(
<>
<
div
className=
"my-3 px-3"
>
<
p
className=
"text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/40"
>
Admin
</
p
>
</
div
>
<
div
className=
"space-y-0.5"
>
{
visibleAdminItems
.
map
((
item
)
=>
{
const
isActive
=
pathname
.
startsWith
(
item
.
href
);
const
Icon
=
item
.
icon
;
return
(
<
Link
key=
{
item
.
href
}
href=
{
item
.
href
}
onClick=
{
()
=>
setIsOpen
(
false
)
}
className=
{
cn
(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors'
,
isActive
?
'bg-accent font-medium'
:
'hover:bg-accent/50 text-muted-foreground'
,
)
}
>
<
Icon
size=
{
18
}
/>
<
span
>
{
item
.
label
}
</
span
>
</
Link
>
);
})
}
</
div
>
</>
)
}
</
nav
>
{
/* User section */
}
{
user
&&
(
<
div
className=
"p-3 border-t"
>
<
Link
href=
"/profile"
onClick=
{
()
=>
setIsOpen
(
false
)
}
className=
"flex items-center gap-3 p-2 rounded-lg hover:bg-accent transition-colors"
>
<
div
className=
"w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold"
>
{
(
user
.
firstName
?.[
0
]
||
''
)
+
(
user
.
lastName
?.[
0
]
||
''
)
}
</
div
>
<
div
>
<
p
className=
"text-sm font-medium"
>
{
user
.
firstName
}
{
user
.
lastName
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground uppercase"
>
{
user
.
role
?.
replace
(
'_'
,
' '
)
}
</
p
>
</
div
>
</
Link
>
</
div
>
)
}
</
div
>
{
/* Bottom tab bar for quick access */
}
<
div
className=
"fixed bottom-0 left-0 right-0 z-40 bg-card border-t flex items-center justify-around py-1.5 px-2 md:hidden safe-area-inset-bottom"
>
{
[
{
href
:
'/'
,
icon
:
LayoutDashboard
,
label
:
'Home'
},
{
href
:
'/boards'
,
icon
:
Kanban
,
label
:
'Boards'
},
{
href
:
'/reports/submit'
,
icon
:
FileText
,
label
:
'Report'
},
{
href
:
'/messages'
,
icon
:
MessageSquare
,
label
:
'Messages'
},
{
href
:
'/notifications'
,
icon
:
Bell
,
label
:
'Alerts'
,
badge
:
unreadCount
},
].
map
((
tab
)
=>
{
const
isActive
=
pathname
===
tab
.
href
||
(
tab
.
href
!==
'/'
&&
pathname
.
startsWith
(
tab
.
href
));
const
Icon
=
tab
.
icon
;
return
(
<
Link
key=
{
tab
.
href
}
href=
{
tab
.
href
}
className=
{
cn
(
'flex flex-col items-center gap-0.5 px-3 py-1 rounded-lg transition-colors relative'
,
isActive
?
'text-primary'
:
'text-muted-foreground'
,
)
}
>
<
Icon
size=
{
20
}
/>
<
span
className=
"text-[9px]"
>
{
tab
.
label
}
</
span
>
{
tab
.
badge
&&
tab
.
badge
>
0
&&
(
<
span
className=
"absolute -top-0.5 right-1 min-w-[14px] h-[14px] bg-destructive text-destructive-foreground rounded-full text-[8px] font-bold flex items-center justify-center px-0.5"
>
{
tab
.
badge
>
99
?
'99+'
:
tab
.
badge
}
</
span
>
)
}
</
Link
>
);
})
}
</
div
>
</>
);
}
\ No newline at end of file
frontend/src/components/shared/error-boundary.tsx
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
Component
,
type
ReactNode
}
from
'react'
;
import
{
AlertTriangle
,
RotateCcw
}
from
'lucide-react'
;
interface
Props
{
children
:
ReactNode
;
fallback
?:
ReactNode
;
}
interface
State
{
hasError
:
boolean
;
error
:
Error
|
null
;
}
export
class
ErrorBoundary
extends
Component
<
Props
,
State
>
{
constructor
(
props
:
Props
)
{
super
(
props
);
this
.
state
=
{
hasError
:
false
,
error
:
null
};
}
static
getDerivedStateFromError
(
error
:
Error
):
State
{
return
{
hasError
:
true
,
error
};
}
componentDidCatch
(
error
:
Error
,
errorInfo
:
React
.
ErrorInfo
)
{
console
.
error
(
'[ErrorBoundary] Caught error:'
,
error
,
errorInfo
);
}
handleReset
=
()
=>
{
this
.
setState
({
hasError
:
false
,
error
:
null
});
};
render
()
{
if
(
this
.
state
.
hasError
)
{
if
(
this
.
props
.
fallback
)
return
this
.
props
.
fallback
;
return
(
<
div
className=
"min-h-[300px] flex flex-col items-center justify-center p-8 text-center"
>
<
div
className=
"w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4"
>
<
AlertTriangle
size=
{
28
}
className=
"text-destructive"
/>
</
div
>
<
h2
className=
"text-lg font-semibold mb-2"
>
Something went wrong
</
h2
>
<
p
className=
"text-sm text-muted-foreground mb-1 max-w-md"
>
An unexpected error occurred while rendering this section.
</
p
>
{
this
.
state
.
error
&&
(
<
pre
className=
"text-xs text-muted-foreground bg-muted rounded-lg p-3 mt-2 max-w-md overflow-auto"
>
{
this
.
state
.
error
.
message
}
</
pre
>
)
}
<
button
onClick=
{
this
.
handleReset
}
className=
"mt-4 flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<
RotateCcw
size=
{
14
}
/>
Try Again
</
button
>
</
div
>
);
}
return
this
.
props
.
children
;
}
}
\ No newline at end of file
frontend/src/hooks/use-media-query.ts
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
export
function
useMediaQuery
(
query
:
string
):
boolean
{
const
[
matches
,
setMatches
]
=
useState
(
false
);
useEffect
(()
=>
{
if
(
typeof
window
===
'undefined'
)
return
;
const
mediaQuery
=
window
.
matchMedia
(
query
);
setMatches
(
mediaQuery
.
matches
);
const
handler
=
(
event
:
MediaQueryListEvent
)
=>
{
setMatches
(
event
.
matches
);
};
mediaQuery
.
addEventListener
(
'change'
,
handler
);
return
()
=>
mediaQuery
.
removeEventListener
(
'change'
,
handler
);
},
[
query
]);
return
matches
;
}
export
function
useIsMobile
():
boolean
{
return
useMediaQuery
(
'(max-width: 768px)'
);
}
export
function
useIsTablet
():
boolean
{
return
useMediaQuery
(
'(min-width: 769px) and (max-width: 1024px)'
);
}
export
function
useIsDesktop
():
boolean
{
return
useMediaQuery
(
'(min-width: 1025px)'
);
}
export
function
usePrefersDarkMode
():
boolean
{
return
useMediaQuery
(
'(prefers-color-scheme: dark)'
);
}
export
function
usePrefersReducedMotion
():
boolean
{
return
useMediaQuery
(
'(prefers-reduced-motion: reduce)'
);
}
\ No newline at end of file
frontend/src/hooks/use-online-status.ts
0 → 100644
View file @
524d1e1e
'use client'
;
import
{
useState
,
useEffect
,
useCallback
,
useRef
}
from
'react'
;
import
{
toast
}
from
'sonner'
;
interface
QueuedAction
{
id
:
string
;
type
:
string
;
url
:
string
;
method
:
string
;
body
?:
any
;
timestamp
:
number
;
}
const
QUEUE_KEY
=
'thegrind_offline_queue'
;
function
getQueue
():
QueuedAction
[]
{
if
(
typeof
window
===
'undefined'
)
return
[];
try
{
const
raw
=
localStorage
.
getItem
(
QUEUE_KEY
);
return
raw
?
JSON
.
parse
(
raw
)
:
[];
}
catch
{
return
[];
}
}
function
saveQueue
(
queue
:
QueuedAction
[]):
void
{
if
(
typeof
window
===
'undefined'
)
return
;
localStorage
.
setItem
(
QUEUE_KEY
,
JSON
.
stringify
(
queue
));
}
export
function
useOnlineStatus
()
{
const
[
isOnline
,
setIsOnline
]
=
useState
(
true
);
const
[
queueLength
,
setQueueLength
]
=
useState
(
0
);
const
isSyncing
=
useRef
(
false
);
useEffect
(()
=>
{
if
(
typeof
window
===
'undefined'
)
return
;
setIsOnline
(
navigator
.
onLine
);
setQueueLength
(
getQueue
().
length
);
const
handleOnline
=
()
=>
{
setIsOnline
(
true
);
toast
.
success
(
'Back online! Syncing queued actions...'
);
syncQueue
();
};
const
handleOffline
=
()
=>
{
setIsOnline
(
false
);
toast
.
warning
(
'You are offline. Changes will be queued and synced when reconnected.'
);
};
window
.
addEventListener
(
'online'
,
handleOnline
);
window
.
addEventListener
(
'offline'
,
handleOffline
);
return
()
=>
{
window
.
removeEventListener
(
'online'
,
handleOnline
);
window
.
removeEventListener
(
'offline'
,
handleOffline
);
};
},
[]);
const
enqueue
=
useCallback
((
action
:
Omit
<
QueuedAction
,
'id'
|
'timestamp'
>
)
=>
{
const
queue
=
getQueue
();
const
newAction
:
QueuedAction
=
{
...
action
,
id
:
`
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
9
)}
`
,
timestamp
:
Date
.
now
(),
};
queue
.
push
(
newAction
);
saveQueue
(
queue
);
setQueueLength
(
queue
.
length
);
return
newAction
.
id
;
},
[]);
const
syncQueue
=
useCallback
(
async
()
=>
{
if
(
isSyncing
.
current
)
return
;
isSyncing
.
current
=
true
;
const
queue
=
getQueue
();
if
(
queue
.
length
===
0
)
{
isSyncing
.
current
=
false
;
return
;
}
const
failed
:
QueuedAction
[]
=
[];
let
successCount
=
0
;
for
(
const
action
of
queue
)
{
try
{
const
token
=
typeof
window
!==
'undefined'
?
localStorage
.
getItem
(
'accessToken'
)
:
null
;
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
token
)
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
const
response
=
await
fetch
(
action
.
url
,
{
method
:
action
.
method
,
headers
,
body
:
action
.
body
?
JSON
.
stringify
(
action
.
body
)
:
undefined
,
});
if
(
response
.
ok
)
{
successCount
++
;
}
else
if
(
response
.
status
>=
500
)
{
failed
.
push
(
action
);
}
// 4xx errors are dropped (client error, no point retrying)
}
catch
{
failed
.
push
(
action
);
}
}
saveQueue
(
failed
);
setQueueLength
(
failed
.
length
);
if
(
successCount
>
0
)
{
toast
.
success
(
`Synced
${
successCount
}
queued action
${
successCount
>
1
?
's'
:
''
}
`
);
}
if
(
failed
.
length
>
0
)
{
toast
.
error
(
`
${
failed
.
length
}
action
${
failed
.
length
>
1
?
's'
:
''
}
failed to sync. Will retry later.`
);
}
isSyncing
.
current
=
false
;
},
[]);
const
clearQueue
=
useCallback
(()
=>
{
saveQueue
([]);
setQueueLength
(
0
);
},
[]);
return
{
isOnline
,
queueLength
,
enqueue
,
syncQueue
,
clearQueue
};
}
\ No newline at end of file
frontend/src/lib/validators.ts
0 → 100644
View file @
524d1e1e
This diff is collapsed.
Click to expand it.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment