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
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
# THE GRIND — Backend Environment Variables
# ============================
# ============================
================
# App
# App
lication
NODE_ENV=development
NODE_ENV=development
PORT=3001
PORT=3001
FRONTEND_URL=http://localhost:3000
FRONTEND_URL=http://localhost:3000
...
@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi
...
@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi
# Redis
# Redis
REDIS_HOST=localhost
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PORT=6379
REDIS_PASSWORD=
#
REDIS_PASSWORD=
REDIS_DB=0
REDIS_DB=0
# JWT
# 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_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY_DAYS=7
JWT_REFRESH_EXPIRY_DAYS=7
# MinIO
# MinIO
(S3-compatible storage)
MINIO_ENDPOINT=localhost
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_USE_SSL=false
...
@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files
...
@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files
MAX_FILE_SIZE_BYTES=26214400
MAX_FILE_SIZE_BYTES=26214400
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
# Rate Limiting
# Session & Security
SESSION_TIMEOUT_HOURS=8
MAX_LOGIN_ATTEMPTS=5
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
LOCKOUT_DURATION_MINUTES=30
SESSION_TIMEOUT_HOURS=8
MAX_DAILY_LOGIN_ATTEMPTS=15
\ No newline at end of file
\ 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
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Backend WebSocket URL (for Socket.io client)
NEXT_PUBLIC_WS_URL=http://localhost:3001
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
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPost
,
apiPut
,
apiDelete
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
ConfirmDialog
}
from
'@/components/shared/confirm-dialog'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatDate
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Layers
,
Kanban
,
FileText
,
AlertTriangle
,
Plus
,
Edit2
,
Trash2
,
Copy
,
Loader2
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
type
TemplateTab
=
'board'
|
'card'
|
'deduction-preset'
;
export
default
function
TemplatesPage
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
<
TemplateTab
>
(
'board'
);
const
[
boardTemplates
,
setBoardTemplates
]
=
useState
<
any
[]
>
([]);
const
[
cardTemplates
,
setCardTemplates
]
=
useState
<
any
[]
>
([]);
const
[
deductionPresets
,
setDeductionPresets
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
{
type
:
TemplateTab
;
item
:
any
}
|
null
>
(
null
);
// Board template form
const
[
showBoardForm
,
setShowBoardForm
]
=
useState
(
false
);
const
[
boardForm
,
setBoardForm
]
=
useState
({
name
:
''
,
description
:
''
});
const
[
isSavingBoard
,
setIsSavingBoard
]
=
useState
(
false
);
// Card template form
const
[
showCardForm
,
setShowCardForm
]
=
useState
(
false
);
const
[
cardForm
,
setCardForm
]
=
useState
({
name
:
''
,
title
:
''
,
description
:
''
,
priority
:
'NONE'
,
estimatedHours
:
0
,
boardId
:
''
,
});
const
[
isSavingCard
,
setIsSavingCard
]
=
useState
(
false
);
const
[
boards
,
setBoards
]
=
useState
<
any
[]
>
([]);
// Deduction preset form
const
[
showPresetForm
,
setShowPresetForm
]
=
useState
(
false
);
const
[
presetForm
,
setPresetForm
]
=
useState
({
name
:
''
,
category
:
'A'
,
subCategory
:
'A1'
,
description
:
''
,
defaultAmountPiasters
:
0
,
});
const
[
isSavingPreset
,
setIsSavingPreset
]
=
useState
(
false
);
useEffect
(()
=>
{
loadData
();
},
[]);
const
loadData
=
async
()
=>
{
setIsLoading
(
true
);
try
{
const
[
boardRes
,
cardRes
,
boardListRes
]
=
await
Promise
.
all
([
apiGet
(
'/boards'
,
{
limit
:
100
,
isTemplate
:
true
}).
catch
(()
=>
({
data
:
[]
})),
apiGet
(
'/cards/templates'
,
{
limit
:
100
}).
catch
(()
=>
({
data
:
[]
})),
apiGet
(
'/boards'
,
{
limit
:
100
}),
]);
setBoardTemplates
(
boardRes
.
data
||
[]);
setCardTemplates
(
cardRes
.
data
||
[]);
setBoards
(
boardListRes
.
data
||
[]);
// Try loading deduction presets
try
{
const
presetRes
=
await
apiGet
(
'/deductions/presets'
,
{
limit
:
100
});
setDeductionPresets
(
presetRes
.
data
||
[]);
}
catch
{
setDeductionPresets
([]);
}
}
catch
(
err
)
{
console
.
error
(
'Failed to load templates:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleCreateBoardTemplate
=
async
()
=>
{
if
(
!
boardForm
.
name
)
{
toast
.
error
(
'Name is required'
);
return
;
}
setIsSavingBoard
(
true
);
try
{
await
apiPost
(
'/boards/templates'
,
boardForm
);
toast
.
success
(
'Board template created'
);
setShowBoardForm
(
false
);
setBoardForm
({
name
:
''
,
description
:
''
});
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create template'
);
}
finally
{
setIsSavingBoard
(
false
);
}
};
const
handleCreateCardTemplate
=
async
()
=>
{
if
(
!
cardForm
.
name
||
!
cardForm
.
title
)
{
toast
.
error
(
'Name and title are required'
);
return
;
}
setIsSavingCard
(
true
);
try
{
await
apiPost
(
'/cards/templates'
,
cardForm
);
toast
.
success
(
'Card template created'
);
setShowCardForm
(
false
);
setCardForm
({
name
:
''
,
title
:
''
,
description
:
''
,
priority
:
'NONE'
,
estimatedHours
:
0
,
boardId
:
''
});
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create template'
);
}
finally
{
setIsSavingCard
(
false
);
}
};
const
handleCreatePreset
=
async
()
=>
{
if
(
!
presetForm
.
name
)
{
toast
.
error
(
'Name is required'
);
return
;
}
setIsSavingPreset
(
true
);
try
{
await
apiPost
(
'/deductions/presets'
,
presetForm
);
toast
.
success
(
'Deduction preset created'
);
setShowPresetForm
(
false
);
setPresetForm
({
name
:
''
,
category
:
'A'
,
subCategory
:
'A1'
,
description
:
''
,
defaultAmountPiasters
:
0
});
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create preset'
);
}
finally
{
setIsSavingPreset
(
false
);
}
};
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
try
{
const
endpoints
:
Record
<
TemplateTab
,
string
>
=
{
board
:
`/boards/templates/
${
deleteTarget
.
item
.
id
}
`
,
card
:
`/cards/templates/
${
deleteTarget
.
item
.
id
}
`
,
'deduction-preset'
:
`/deductions/presets/
${
deleteTarget
.
item
.
id
}
`
,
};
await
apiDelete
(
endpoints
[
deleteTarget
.
type
]);
toast
.
success
(
'Deleted successfully'
);
setDeleteTarget
(
null
);
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to delete'
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Templates & Presets"
description=
"Manage reusable board templates, card templates, and deduction presets"
/>
{
/* Tabs */
}
<
div
className=
"flex gap-1 border-b pb-0"
>
{
[
{
key
:
'board'
as
const
,
label
:
'Board Templates'
,
icon
:
Kanban
,
count
:
boardTemplates
.
length
},
{
key
:
'card'
as
const
,
label
:
'Card Templates'
,
icon
:
FileText
,
count
:
cardTemplates
.
length
},
{
key
:
'deduction-preset'
as
const
,
label
:
'Deduction Presets'
,
icon
:
AlertTriangle
,
count
:
deductionPresets
.
length
},
].
map
((
tab
)
=>
{
const
Icon
=
tab
.
icon
;
return
(
<
button
key=
{
tab
.
key
}
onClick=
{
()
=>
setActiveTab
(
tab
.
key
)
}
className=
{
cn
(
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px'
,
activeTab
===
tab
.
key
?
'border-primary text-primary'
:
'border-transparent text-muted-foreground hover:text-foreground'
,
)
}
>
<
Icon
size=
{
16
}
/>
{
tab
.
label
}
<
span
className=
"text-xs bg-muted px-1.5 py-0.5 rounded-full"
>
{
tab
.
count
}
</
span
>
</
button
>
);
})
}
</
div
>
{
/* Board Templates Tab */
}
{
activeTab
===
'board'
&&
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex justify-end"
>
<
button
onClick=
{
()
=>
setShowBoardForm
(
!
showBoardForm
)
}
className=
"flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<
Plus
size=
{
16
}
/>
New Board Template
</
button
>
</
div
>
{
showBoardForm
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4"
>
<
h3
className=
"font-semibold"
>
New Board Template
</
h3
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Template Name *
</
label
>
<
input
type=
"text"
value=
{
boardForm
.
name
}
onChange=
{
(
e
)
=>
setBoardForm
({
...
boardForm
,
name
:
e
.
target
.
value
})
}
placeholder=
"e.g., Game Project Board"
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Description
</
label
>
<
input
type=
"text"
value=
{
boardForm
.
description
}
onChange=
{
(
e
)
=>
setBoardForm
({
...
boardForm
,
description
:
e
.
target
.
value
})
}
placeholder=
"What is this template for?"
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
</
div
>
<
div
className=
"flex justify-end gap-2"
>
<
button
onClick=
{
()
=>
setShowBoardForm
(
false
)
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleCreateBoardTemplate
}
disabled=
{
isSavingBoard
}
className=
"flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50"
>
{
isSavingBoard
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Layers
size=
{
14
}
/>
}
Create
</
button
>
</
div
>
</
div
>
)
}
{
boardTemplates
.
length
===
0
?
(
<
EmptyState
icon=
{
Kanban
}
title=
"No board templates"
description=
"Create a board template to quickly set up new projects."
/>
)
:
(
<
div
className=
"grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{
boardTemplates
.
map
((
tpl
)
=>
(
<
div
key=
{
tpl
.
id
}
className=
"bg-card rounded-xl border p-4 hover:border-primary/30 transition-all"
>
<
div
className=
"flex items-start justify-between mb-3"
>
<
div
>
<
h4
className=
"text-sm font-semibold"
>
{
tpl
.
name
}
</
h4
>
{
tpl
.
description
&&
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
tpl
.
description
}
</
p
>
}
</
div
>
<
div
className=
"flex gap-1"
>
<
button
onClick=
{
()
=>
setDeleteTarget
({
type
:
'board'
,
item
:
tpl
})
}
className=
"p-1.5 text-muted-foreground hover:text-destructive rounded"
><
Trash2
size=
{
14
}
/></
button
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3 text-xs text-muted-foreground"
>
{
tpl
.
columnCount
&&
<
span
>
{
tpl
.
columnCount
}
columns
</
span
>
}
{
tpl
.
createdAt
&&
<
span
>
Created
{
formatDate
(
tpl
.
createdAt
)
}
</
span
>
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
)
}
{
/* Card Templates Tab */
}
{
activeTab
===
'card'
&&
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex justify-end"
>
<
button
onClick=
{
()
=>
setShowCardForm
(
!
showCardForm
)
}
className=
"flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<
Plus
size=
{
16
}
/>
New Card Template
</
button
>
</
div
>
{
showCardForm
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4"
>
<
h3
className=
"font-semibold"
>
New Card Template
</
h3
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Template Name *
</
label
>
<
input
type=
"text"
value=
{
cardForm
.
name
}
onChange=
{
(
e
)
=>
setCardForm
({
...
cardForm
,
name
:
e
.
target
.
value
})
}
placeholder=
"e.g., Bug Report Template"
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Card Title *
</
label
>
<
input
type=
"text"
value=
{
cardForm
.
title
}
onChange=
{
(
e
)
=>
setCardForm
({
...
cardForm
,
title
:
e
.
target
.
value
})
}
placeholder=
"e.g., [BUG] "
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Priority
</
label
>
<
select
value=
{
cardForm
.
priority
}
onChange=
{
(
e
)
=>
setCardForm
({
...
cardForm
,
priority
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"NONE"
>
None
</
option
>
<
option
value=
"LOW"
>
Low
</
option
>
<
option
value=
"MEDIUM"
>
Medium
</
option
>
<
option
value=
"HIGH"
>
High
</
option
>
<
option
value=
"CRITICAL"
>
Critical
</
option
>
</
select
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Board Scope (optional)
</
label
>
<
select
value=
{
cardForm
.
boardId
}
onChange=
{
(
e
)
=>
setCardForm
({
...
cardForm
,
boardId
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
Organization-wide
</
option
>
{
boards
.
map
((
b
)
=>
<
option
key=
{
b
.
id
}
value=
{
b
.
id
}
>
{
b
.
name
}
</
option
>)
}
</
select
>
</
div
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Description Template
</
label
>
<
textarea
value=
{
cardForm
.
description
}
onChange=
{
(
e
)
=>
setCardForm
({
...
cardForm
,
description
:
e
.
target
.
value
})
}
rows=
{
4
}
placeholder=
"Pre-filled description content..."
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"flex justify-end gap-2"
>
<
button
onClick=
{
()
=>
setShowCardForm
(
false
)
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleCreateCardTemplate
}
disabled=
{
isSavingCard
}
className=
"flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50"
>
{
isSavingCard
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
FileText
size=
{
14
}
/>
}
Create
</
button
>
</
div
>
</
div
>
)
}
{
cardTemplates
.
length
===
0
?
(
<
EmptyState
icon=
{
FileText
}
title=
"No card templates"
description=
"Create reusable card templates for recurring task types."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
cardTemplates
.
map
((
tpl
)
=>
(
<
div
key=
{
tpl
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"flex items-center gap-2"
>
<
h4
className=
"text-sm font-semibold"
>
{
tpl
.
name
}
</
h4
>
{
tpl
.
priority
&&
tpl
.
priority
!==
'NONE'
&&
<
StatusBadge
status=
{
tpl
.
priority
}
/>
}
{
tpl
.
boardId
?
(
<
span
className=
"text-[10px] bg-muted px-1.5 py-0.5 rounded"
>
Board-level
</
span
>
)
:
(
<
span
className=
"text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded"
>
Org-wide
</
span
>
)
}
</
div
>
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
tpl
.
title
}
</
p
>
</
div
>
<
button
onClick=
{
()
=>
setDeleteTarget
({
type
:
'card'
,
item
:
tpl
})
}
className=
"p-2 text-muted-foreground hover:text-destructive"
><
Trash2
size=
{
14
}
/></
button
>
</
div
>
))
}
</
div
>
)
}
</
div
>
)
}
{
/* Deduction Presets Tab */
}
{
activeTab
===
'deduction-preset'
&&
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex justify-end"
>
<
button
onClick=
{
()
=>
setShowPresetForm
(
!
showPresetForm
)
}
className=
"flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<
Plus
size=
{
16
}
/>
New Preset
</
button
>
</
div
>
{
showPresetForm
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4"
>
<
h3
className=
"font-semibold"
>
New Deduction Preset
</
h3
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Preset Name *
</
label
>
<
input
type=
"text"
value=
{
presetForm
.
name
}
onChange=
{
(
e
)
=>
setPresetForm
({
...
presetForm
,
name
:
e
.
target
.
value
})
}
placeholder=
"e.g., Late Report — 3rd Occurrence"
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Category
</
label
>
<
select
value=
{
presetForm
.
category
}
onChange=
{
(
e
)
=>
setPresetForm
({
...
presetForm
,
category
:
e
.
target
.
value
,
subCategory
:
e
.
target
.
value
+
'1'
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"A"
>
A — Deadline
</
option
>
<
option
value=
"B"
>
B — Reporting
</
option
>
<
option
value=
"C"
>
C — Quality
</
option
>
<
option
value=
"D"
>
D — Communication
</
option
>
</
select
>
</
div
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Default Description
</
label
>
<
textarea
value=
{
presetForm
.
description
}
onChange=
{
(
e
)
=>
setPresetForm
({
...
presetForm
,
description
:
e
.
target
.
value
})
}
rows=
{
3
}
placeholder=
"Pre-filled violation description..."
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"flex justify-end gap-2"
>
<
button
onClick=
{
()
=>
setShowPresetForm
(
false
)
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleCreatePreset
}
disabled=
{
isSavingPreset
}
className=
"flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50"
>
{
isSavingPreset
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
AlertTriangle
size=
{
14
}
/>
}
Create
</
button
>
</
div
>
</
div
>
)
}
{
deductionPresets
.
length
===
0
?
(
<
EmptyState
icon=
{
AlertTriangle
}
title=
"No deduction presets"
description=
"Create presets to speed up common deduction types."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
deductionPresets
.
map
((
preset
)
=>
(
<
div
key=
{
preset
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"flex items-center gap-2"
>
<
h4
className=
"text-sm font-semibold"
>
{
preset
.
name
}
</
h4
>
<
span
className=
"text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{
preset
.
category
}{
preset
.
subCategory
}
</
span
>
</
div
>
{
preset
.
description
&&
<
p
className=
"text-xs text-muted-foreground mt-0.5 line-clamp-1"
>
{
preset
.
description
}
</
p
>
}
</
div
>
<
button
onClick=
{
()
=>
setDeleteTarget
({
type
:
'deduction-preset'
,
item
:
preset
})
}
className=
"p-2 text-muted-foreground hover:text-destructive"
><
Trash2
size=
{
14
}
/></
button
>
</
div
>
))
}
</
div
>
)
}
</
div
>
)
}
<
ConfirmDialog
open=
{
!!
deleteTarget
}
onClose=
{
()
=>
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
title=
{
`Delete ${deleteTarget?.type === 'board' ? 'Board Template' : deleteTarget?.type === 'card' ? 'Card Template' : 'Deduction Preset'}`
}
description=
"This action cannot be undone. Are you sure?"
confirmLabel=
"Delete"
destructive
/>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/globals.css
View file @
524d1e1e
...
@@ -85,3 +85,71 @@
...
@@ -85,3 +85,71 @@
::-webkit-scrollbar-thumb:hover
{
::-webkit-scrollbar-thumb:hover
{
@apply
bg-muted-foreground/30;
@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
import
{
z
}
from
'zod'
;
// ==========================================
// AUTH VALIDATORS
// ==========================================
export
const
loginSchema
=
z
.
object
({
login
:
z
.
string
().
min
(
1
,
'Username or email is required'
),
password
:
z
.
string
().
min
(
1
,
'Password is required'
),
});
export
const
changePasswordSchema
=
z
.
object
({
currentPassword
:
z
.
string
().
min
(
1
,
'Current password is required'
),
newPassword
:
z
.
string
()
.
min
(
10
,
'Password must be at least 10 characters'
)
.
regex
(
/
[
A-Z
]
/
,
'Must contain at least one uppercase letter'
)
.
regex
(
/
[
a-z
]
/
,
'Must contain at least one lowercase letter'
)
.
regex
(
/
[
0-9
]
/
,
'Must contain at least one number'
)
.
regex
(
/
[^
A-Za-z0-9
]
/
,
'Must contain at least one special character'
),
confirmPassword
:
z
.
string
(),
}).
refine
((
data
)
=>
data
.
newPassword
===
data
.
confirmPassword
,
{
message
:
'Passwords do not match'
,
path
:
[
'confirmPassword'
],
});
// ==========================================
// USER VALIDATORS
// ==========================================
export
const
registerSchema
=
z
.
object
({
firstName
:
z
.
string
().
min
(
2
,
'Min 2 characters'
).
max
(
50
),
lastName
:
z
.
string
().
min
(
2
,
'Min 2 characters'
).
max
(
50
),
nameArabic
:
z
.
string
().
min
(
4
,
'Min 4 characters'
).
max
(
100
),
nationalId
:
z
.
string
().
regex
(
/^
\d{14}
$/
,
'Must be 14 digits'
),
dateOfBirth
:
z
.
string
().
min
(
1
,
'Date of birth is required'
),
phone
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid Egyptian phone number'
),
phoneSecondary
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid phone number'
).
optional
().
or
(
z
.
literal
(
''
)),
address
:
z
.
string
().
min
(
20
,
'Min 20 characters'
),
emergencyContactName
:
z
.
string
().
min
(
4
,
'Min 4 characters'
),
emergencyContactPhone
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid phone number'
),
emergencyContactRelationship
:
z
.
enum
([
'Parent'
,
'Sibling'
,
'Spouse'
,
'Friend'
,
'Other'
]),
bankName
:
z
.
string
().
min
(
1
,
'Bank name is required'
),
bankAccountNumber
:
z
.
string
().
min
(
1
,
'Account number is required'
),
bankAccountHolderName
:
z
.
string
().
min
(
4
,
'Min 4 characters'
),
username
:
z
.
string
().
min
(
3
,
'Min 3 characters'
).
max
(
30
).
regex
(
/^
[
a-zA-Z0-9_
]
+$/
,
'Only alphanumeric and underscore'
),
password
:
z
.
string
()
.
min
(
10
,
'Min 10 characters'
)
.
regex
(
/
[
A-Z
]
/
,
'Must contain uppercase'
)
.
regex
(
/
[
a-z
]
/
,
'Must contain lowercase'
)
.
regex
(
/
[
0-9
]
/
,
'Must contain number'
)
.
regex
(
/
[^
A-Za-z0-9
]
/
,
'Must contain special character'
),
confirmPassword
:
z
.
string
(),
}).
refine
((
data
)
=>
data
.
password
===
data
.
confirmPassword
,
{
message
:
'Passwords do not match'
,
path
:
[
'confirmPassword'
],
});
export
const
updateProfileSchema
=
z
.
object
({
phone
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid phone number'
).
optional
(),
phoneSecondary
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid phone number'
).
optional
().
or
(
z
.
literal
(
''
)),
address
:
z
.
string
().
min
(
20
,
'Min 20 characters'
).
optional
(),
emergencyContactName
:
z
.
string
().
min
(
4
,
'Min 4 characters'
).
optional
(),
emergencyContactPhone
:
z
.
string
().
regex
(
/^01
[
0-9
]{9}
$/
,
'Invalid phone number'
).
optional
(),
emergencyContactRelationship
:
z
.
string
().
optional
(),
bankName
:
z
.
string
().
min
(
1
).
optional
(),
bankAccountNumber
:
z
.
string
().
min
(
1
).
optional
(),
bankAccountHolderName
:
z
.
string
().
min
(
4
).
optional
(),
});
// ==========================================
// BOARD VALIDATORS
// ==========================================
export
const
createBoardSchema
=
z
.
object
({
name
:
z
.
string
().
min
(
1
,
'Board name is required'
).
max
(
100
),
description
:
z
.
string
().
max
(
500
).
optional
(),
key
:
z
.
string
().
min
(
1
,
'Board key is required'
).
max
(
10
).
regex
(
/^
[
A-Z0-9_
]
+$/
,
'Uppercase letters, numbers, underscore only'
),
visibility
:
z
.
enum
([
'PRIVATE'
,
'PUBLIC'
]).
default
(
'PRIVATE'
),
allowContractorCreation
:
z
.
boolean
().
default
(
true
),
autoArchiveDoneCardsDays
:
z
.
number
().
min
(
7
).
max
(
365
).
default
(
30
),
});
// ==========================================
// CARD VALIDATORS
// ==========================================
export
const
createCardSchema
=
z
.
object
({
title
:
z
.
string
().
min
(
1
,
'Title is required'
).
max
(
200
),
description
:
z
.
string
().
optional
(),
priority
:
z
.
enum
([
'CRITICAL'
,
'HIGH'
,
'MEDIUM'
,
'LOW'
,
'NONE'
]).
default
(
'NONE'
),
dueDate
:
z
.
string
().
optional
(),
estimatedHours
:
z
.
number
().
min
(
0
).
optional
(),
bountyPiasters
:
z
.
number
().
min
(
0
).
optional
(),
columnId
:
z
.
string
().
uuid
().
optional
(),
boardId
:
z
.
string
().
uuid
(),
});
export
const
moveCardSchema
=
z
.
object
({
columnId
:
z
.
string
().
uuid
(
'Invalid column'
),
position
:
z
.
number
().
min
(
0
),
frozenReason
:
z
.
string
().
min
(
20
,
'Frozen reason must be at least 20 characters'
).
optional
(),
});
// ==========================================
// REPORT VALIDATORS
// ==========================================
export
const
taskEntrySchema
=
z
.
object
({
cardId
:
z
.
string
().
optional
(),
description
:
z
.
string
().
min
(
50
,
'Description must be at least 50 characters'
),
timeSpentMinutes
:
z
.
number
().
min
(
15
,
'Minimum 15 minutes'
),
status
:
z
.
enum
([
'IN_PROGRESS'
,
'COMPLETED'
,
'BLOCKED'
]),
});
export
const
submitReportSchema
=
z
.
object
({
reportDate
:
z
.
string
().
min
(
1
,
'Report date is required'
),
taskEntries
:
z
.
array
(
taskEntrySchema
).
min
(
1
,
'At least one task entry is required'
),
blockers
:
z
.
string
().
optional
(),
additionalNotes
:
z
.
string
().
optional
(),
mood
:
z
.
enum
([
'FRUSTRATED'
,
'NEUTRAL'
,
'GOOD'
,
'ON_FIRE'
]).
optional
(),
});
// ==========================================
// DEDUCTION VALIDATORS
// ==========================================
export
const
createDeductionSchema
=
z
.
object
({
userId
:
z
.
string
().
uuid
(
'Select a contractor'
),
category
:
z
.
enum
([
'A'
,
'B'
,
'C'
,
'D'
]),
subCategory
:
z
.
string
().
min
(
2
),
cardId
:
z
.
string
().
uuid
().
optional
(),
violationDate
:
z
.
string
().
min
(
1
,
'Violation date is required'
),
description
:
z
.
string
().
min
(
100
,
'Description must be at least 100 characters'
),
amountPiasters
:
z
.
number
().
min
(
0
).
optional
(),
});
export
const
respondDeductionSchema
=
z
.
object
({
response
:
z
.
enum
([
'ACCEPT'
,
'DISPUTE'
]),
responseText
:
z
.
string
().
optional
(),
}).
refine
(
(
data
)
=>
data
.
response
!==
'DISPUTE'
||
(
data
.
responseText
&&
data
.
responseText
.
length
>=
100
),
{
message
:
'Dispute explanation must be at least 100 characters'
,
path
:
[
'responseText'
]
},
);
// ==========================================
// ADJUSTMENT VALIDATORS
// ==========================================
export
const
createAdjustmentSchema
=
z
.
object
({
userId
:
z
.
string
().
uuid
(
'Select a contractor'
),
type
:
z
.
enum
([
'POSITIVE'
,
'NEGATIVE'
]),
category
:
z
.
enum
([
'ADVANCE'
,
'REIMBURSEMENT'
,
'BONUS'
,
'CORRECTION'
,
'LOAN'
,
'OTHER'
]),
amountPiasters
:
z
.
number
().
min
(
1
,
'Amount must be positive'
),
description
:
z
.
string
().
min
(
50
,
'Description must be at least 50 characters'
),
effectiveMonth
:
z
.
number
().
min
(
1
).
max
(
12
),
effectiveYear
:
z
.
number
().
min
(
2020
),
});
// ==========================================
// PIP VALIDATORS
// ==========================================
export
const
createPipSchema
=
z
.
object
({
userId
:
z
.
string
().
uuid
(
'Select a contractor'
),
durationDays
:
z
.
number
().
min
(
30
).
max
(
60
),
specificIssues
:
z
.
string
().
min
(
50
,
'Specific issues must be at least 50 characters'
),
improvementTargets
:
z
.
string
().
min
(
50
,
'Improvement targets must be at least 50 characters'
),
successCriteria
:
z
.
string
().
min
(
100
,
'Success criteria must be at least 100 characters'
),
consequenceOfFailure
:
z
.
string
().
default
(
'Termination of engagement.'
),
checkInSchedule
:
z
.
enum
([
'WEEKLY'
,
'BIWEEKLY'
]).
default
(
'WEEKLY'
),
});
// ==========================================
// EVALUATION VALIDATORS
// ==========================================
export
const
technicalEvalSchema
=
z
.
object
({
codeQuality
:
z
.
number
().
min
(
1
).
max
(
5
),
codeQualityNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
taskCompletion
:
z
.
number
().
min
(
1
).
max
(
5
),
deadlineCompliance
:
z
.
number
().
min
(
1
).
max
(
5
),
technicalGrowth
:
z
.
number
().
min
(
1
).
max
(
5
),
technicalGrowthNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
problemSolving
:
z
.
number
().
min
(
1
).
max
(
5
),
problemSolvingNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
});
export
const
professionalEvalSchema
=
z
.
object
({
reportingCompliance
:
z
.
number
().
min
(
1
).
max
(
5
),
communicationQuality
:
z
.
number
().
min
(
1
).
max
(
5
),
communicationNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
collaboration
:
z
.
number
().
min
(
1
).
max
(
5
),
collaborationNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
reliability
:
z
.
number
().
min
(
1
).
max
(
5
),
reliabilityNotes
:
z
.
string
().
min
(
20
,
'Justification required (min 20 chars)'
),
policyCompliance
:
z
.
number
().
min
(
1
).
max
(
5
),
});
// ==========================================
// MEETING VALIDATORS
// ==========================================
export
const
createMeetingSchema
=
z
.
object
({
title
:
z
.
string
().
min
(
1
,
'Title is required'
).
max
(
200
),
description
:
z
.
string
().
optional
(),
startTime
:
z
.
string
().
min
(
1
,
'Start time is required'
),
endTime
:
z
.
string
().
min
(
1
,
'End time is required'
),
inviteeIds
:
z
.
array
(
z
.
string
().
uuid
()).
min
(
1
,
'At least one invitee is required'
),
location
:
z
.
string
().
optional
(),
recurrence
:
z
.
enum
([
'NONE'
,
'WEEKLY'
,
'BIWEEKLY'
,
'MONTHLY'
]).
default
(
'NONE'
),
});
// ==========================================
// SCHEDULE CHANGE VALIDATORS
// ==========================================
export
const
scheduleChangeSchema
=
z
.
object
({
proposedSchedule
:
z
.
record
(
z
.
string
(),
z
.
enum
([
'IN_OFFICE'
,
'REMOTE'
,
'OFF'
])),
effectiveDate
:
z
.
string
().
min
(
1
,
'Effective date is required'
),
reason
:
z
.
string
().
min
(
50
,
'Reason must be at least 50 characters'
),
});
// ==========================================
// NOTICE VALIDATORS
// ==========================================
export
const
createNoticeSchema
=
z
.
object
({
title
:
z
.
string
().
min
(
1
,
'Title is required'
).
max
(
200
),
content
:
z
.
string
().
min
(
1
,
'Content is required'
),
type
:
z
.
enum
([
'GENERAL_ANNOUNCEMENT'
,
'OFFICIAL_WARNING'
,
'POLICY_UPDATE'
,
'CUSTOM'
]),
isBlocking
:
z
.
boolean
().
default
(
false
),
targetRoles
:
z
.
array
(
z
.
string
()).
optional
(),
});
// ==========================================
// WEBHOOK VALIDATORS
// ==========================================
export
const
createWebhookSchema
=
z
.
object
({
url
:
z
.
string
().
url
(
'Must be a valid URL'
),
secret
:
z
.
string
().
optional
(),
events
:
z
.
array
(
z
.
string
()).
min
(
1
,
'Select at least one event'
),
isActive
:
z
.
boolean
().
default
(
true
),
});
// Type exports for forms
export
type
LoginFormData
=
z
.
infer
<
typeof
loginSchema
>
;
export
type
ChangePasswordFormData
=
z
.
infer
<
typeof
changePasswordSchema
>
;
export
type
RegisterFormData
=
z
.
infer
<
typeof
registerSchema
>
;
export
type
CreateBoardFormData
=
z
.
infer
<
typeof
createBoardSchema
>
;
export
type
CreateCardFormData
=
z
.
infer
<
typeof
createCardSchema
>
;
export
type
SubmitReportFormData
=
z
.
infer
<
typeof
submitReportSchema
>
;
export
type
CreateDeductionFormData
=
z
.
infer
<
typeof
createDeductionSchema
>
;
export
type
CreateAdjustmentFormData
=
z
.
infer
<
typeof
createAdjustmentSchema
>
;
export
type
CreatePipFormData
=
z
.
infer
<
typeof
createPipSchema
>
;
export
type
CreateMeetingFormData
=
z
.
infer
<
typeof
createMeetingSchema
>
;
export
type
ScheduleChangeFormData
=
z
.
infer
<
typeof
scheduleChangeSchema
>
;
\ No newline at end of file
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