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
83d8f490
Commit
83d8f490
authored
Apr 02, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 12 files via Son of Anton
parent
d6979653
Changes
12
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
2090 additions
and
0 deletions
+2090
-0
page.tsx
frontend/src/app/(dashboard)/admin/audit-trail/page.tsx
+167
-0
page.tsx
frontend/src/app/(dashboard)/admin/settings/page.tsx
+136
-0
page.tsx
frontend/src/app/(dashboard)/availability/page.tsx
+194
-0
page.tsx
...d/src/app/(dashboard)/evaluations/[evaluationId]/page.tsx
+170
-0
page.tsx
frontend/src/app/(dashboard)/evaluations/page.tsx
+93
-0
page.tsx
frontend/src/app/(dashboard)/learning/page.tsx
+131
-0
page.tsx
frontend/src/app/(dashboard)/meetings/page.tsx
+101
-0
page.tsx
frontend/src/app/(dashboard)/profile/page.tsx
+233
-0
page.tsx
frontend/src/app/(dashboard)/reports/[reportId]/page.tsx
+197
-0
page.tsx
frontend/src/app/(dashboard)/reports/page.tsx
+169
-0
page.tsx
frontend/src/app/(dashboard)/reports/submit/page.tsx
+317
-0
page.tsx
frontend/src/app/(dashboard)/schedule/page.tsx
+182
-0
No files found.
frontend/src/app/(dashboard)/admin/audit-trail/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
}
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
{
formatDateTime
}
from
'@/lib/date'
;
import
{
Shield
,
Search
,
Download
}
from
'lucide-react'
;
export
default
function
AuditTrailPage
()
{
const
[
entries
,
setEntries
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
actionFilter
,
setActionFilter
]
=
useState
(
''
);
const
[
entityFilter
,
setEntityFilter
]
=
useState
(
''
);
const
[
searchUser
,
setSearchUser
]
=
useState
(
''
);
useEffect
(()
=>
{
loadEntries
();
},
[
page
,
actionFilter
,
entityFilter
]);
const
loadEntries
=
async
()
=>
{
try
{
const
params
:
any
=
{
page
,
limit
:
30
,
sortOrder
:
'desc'
};
if
(
actionFilter
)
params
.
action
=
actionFilter
;
if
(
entityFilter
)
params
.
entityType
=
entityFilter
;
if
(
searchUser
)
params
.
userId
=
searchUser
;
const
res
=
await
apiGet
(
'/audit-trail'
,
params
);
setEntries
(
res
.
data
||
[]);
setTotal
(
res
.
meta
?.
total
||
0
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load audit trail:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Audit Trail"
description=
{
`${total} total entries`
}
actions=
{
<
a
href=
"/api/audit-trail/export"
target=
"_blank"
className=
"flex items-center gap-2 px-3 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<
Download
size=
{
14
}
/>
Export
</
a
>
}
/>
{
/* Filters */
}
<
div
className=
"flex items-center gap-3 flex-wrap"
>
<
select
value=
{
actionFilter
}
onChange=
{
(
e
)
=>
{
setActionFilter
(
e
.
target
.
value
);
setPage
(
1
);
}
}
className=
"px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
All Actions
</
option
>
<
option
value=
"CREATE"
>
Create
</
option
>
<
option
value=
"UPDATE"
>
Update
</
option
>
<
option
value=
"DELETE"
>
Delete
</
option
>
<
option
value=
"LOGIN"
>
Login
</
option
>
<
option
value=
"LOGOUT"
>
Logout
</
option
>
<
option
value=
"MOVE"
>
Move
</
option
>
<
option
value=
"ASSIGN"
>
Assign
</
option
>
<
option
value=
"APPROVE"
>
Approve
</
option
>
<
option
value=
"ACKNOWLEDGE"
>
Acknowledge
</
option
>
</
select
>
<
select
value=
{
entityFilter
}
onChange=
{
(
e
)
=>
{
setEntityFilter
(
e
.
target
.
value
);
setPage
(
1
);
}
}
className=
"px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
All Entities
</
option
>
<
option
value=
"auth"
>
Auth
</
option
>
<
option
value=
"users"
>
Users
</
option
>
<
option
value=
"boards"
>
Boards
</
option
>
<
option
value=
"cards"
>
Cards
</
option
>
<
option
value=
"deductions"
>
Deductions
</
option
>
<
option
value=
"reports"
>
Reports
</
option
>
<
option
value=
"payroll"
>
Payroll
</
option
>
<
option
value=
"evaluations"
>
Evaluations
</
option
>
<
option
value=
"notifications"
>
Notifications
</
option
>
<
option
value=
"settings"
>
Settings
</
option
>
</
select
>
</
div
>
{
/* Table */
}
{
entries
.
length
===
0
?
(
<
EmptyState
icon=
{
Shield
}
title=
"No audit entries"
description=
"The audit trail will populate as actions are performed."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border overflow-hidden"
>
<
div
className=
"overflow-x-auto"
>
<
table
className=
"w-full text-sm"
>
<
thead
>
<
tr
className=
"border-b bg-muted/50"
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Time
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
User
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Action
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Entity
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Method
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
IP
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
entries
.
map
((
entry
)
=>
(
<
tr
key=
{
entry
.
id
}
className=
"hover:bg-accent/50"
>
<
td
className=
"px-4 py-3 text-xs text-muted-foreground whitespace-nowrap"
>
{
entry
.
createdAt
?
formatDateTime
(
entry
.
createdAt
)
:
'—'
}
</
td
>
<
td
className=
"px-4 py-3 text-xs"
>
{
entry
.
user
?
(
<
span
>
{
entry
.
user
.
firstName
}
{
entry
.
user
.
lastName
}
</
span
>
)
:
(
<
span
className=
"text-muted-foreground"
>
System
</
span
>
)
}
</
td
>
<
td
className=
"px-4 py-3"
>
<
span
className=
"text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{
entry
.
action
}
</
span
>
</
td
>
<
td
className=
"px-4 py-3 text-xs text-muted-foreground"
>
{
entry
.
entityType
}
</
td
>
<
td
className=
"px-4 py-3 text-xs font-mono text-muted-foreground"
>
{
entry
.
method
}
</
td
>
<
td
className=
"px-4 py-3 text-xs text-muted-foreground"
>
{
entry
.
ipAddress
}
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
{
total
>
30
&&
(
<
div
className=
"flex items-center justify-between px-4 py-3 border-t"
>
<
span
className=
"text-xs text-muted-foreground"
>
Page
{
page
}
of
{
Math
.
ceil
(
total
/
30
)
}
</
span
>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
Math
.
max
(
1
,
p
-
1
))
}
disabled=
{
page
<=
1
}
className=
"px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Previous
</
button
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
p
+
1
)
}
disabled=
{
page
>=
Math
.
ceil
(
total
/
30
)
}
className=
"px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next
</
button
>
</
div
>
</
div
>
)
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/settings/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPut
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
Settings
,
Save
,
Loader2
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
SettingsPage
()
{
const
[
settings
,
setSettings
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
isSaving
,
setIsSaving
]
=
useState
(
false
);
const
[
editedValues
,
setEditedValues
]
=
useState
<
Record
<
string
,
any
>>
({});
useEffect
(()
=>
{
apiGet
(
'/settings'
)
.
then
((
res
)
=>
{
const
data
=
Array
.
isArray
(
res
.
data
)
?
res
.
data
:
Object
.
entries
(
res
.
data
||
{}).
map
(([
key
,
val
])
=>
({
key
,
value
:
val
}));
setSettings
(
data
);
})
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[]);
const
handleChange
=
(
key
:
string
,
value
:
any
)
=>
{
setEditedValues
((
prev
)
=>
({
...
prev
,
[
key
]:
value
}));
};
const
handleSave
=
async
()
=>
{
if
(
Object
.
keys
(
editedValues
).
length
===
0
)
return
;
setIsSaving
(
true
);
try
{
await
apiPut
(
'/settings'
,
{
settings
:
editedValues
});
toast
.
success
(
'Settings saved'
);
setEditedValues
({});
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to save settings'
);
}
finally
{
setIsSaving
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
const
groups
:
Record
<
string
,
any
[]
>
=
{};
for
(
const
s
of
settings
)
{
const
group
=
s
.
key
?.
split
(
/
(?=[
A-Z
])
/
)[
0
]
||
'General'
;
if
(
!
groups
[
group
])
groups
[
group
]
=
[];
groups
[
group
].
push
(
s
);
}
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"System Settings"
description=
"Configure platform behavior (Super Admin only)"
actions=
{
Object
.
keys
(
editedValues
).
length
>
0
&&
(
<
button
onClick=
{
handleSave
}
disabled=
{
isSaving
}
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 disabled:opacity-50"
>
{
isSaving
?
<
Loader2
size=
{
16
}
className=
"animate-spin"
/>
:
<
Save
size=
{
16
}
/>
}
Save Changes (
{
Object
.
keys
(
editedValues
).
length
}
)
</
button
>
)
}
/>
{
settings
.
length
===
0
?
(
<
div
className=
"bg-card rounded-xl border p-6 text-center text-muted-foreground"
>
<
Settings
size=
{
32
}
className=
"mx-auto mb-2 opacity-30"
/>
<
p
>
No configurable settings found.
</
p
>
</
div
>
)
:
(
<
div
className=
"space-y-6"
>
{
Object
.
entries
(
groups
).
map
(([
group
,
items
])
=>
(
<
div
key=
{
group
}
className=
"bg-card rounded-xl border"
>
<
div
className=
"p-4 border-b"
>
<
h3
className=
"font-semibold capitalize"
>
{
group
}
</
h3
>
</
div
>
<
div
className=
"divide-y"
>
{
items
.
map
((
setting
)
=>
{
const
currentValue
=
editedValues
[
setting
.
key
]
??
setting
.
value
;
const
isNumber
=
typeof
setting
.
value
===
'number'
;
const
isBool
=
typeof
setting
.
value
===
'boolean'
;
return
(
<
div
key=
{
setting
.
key
}
className=
"p-4 flex items-center justify-between gap-4"
>
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
"text-sm font-medium"
>
{
setting
.
key
}
</
p
>
{
setting
.
description
&&
(
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
setting
.
description
}
</
p
>
)
}
</
div
>
<
div
className=
"w-48 shrink-0"
>
{
isBool
?
(
<
button
onClick=
{
()
=>
handleChange
(
setting
.
key
,
!
currentValue
)
}
className=
{
`px-3 py-1.5 rounded-lg text-xs font-medium ${
currentValue
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-muted text-muted-foreground'
}`
}
>
{
currentValue
?
'Enabled'
:
'Disabled'
}
</
button
>
)
:
isNumber
?
(
<
input
type=
"number"
value=
{
currentValue
}
onChange=
{
(
e
)
=>
handleChange
(
setting
.
key
,
Number
(
e
.
target
.
value
))
}
className=
"w-full px-3 py-1.5 rounded-lg border bg-background text-sm text-right focus:outline-none focus:ring-2 focus:ring-ring"
/>
)
:
(
<
input
type=
"text"
value=
{
String
(
currentValue
||
''
)
}
onChange=
{
(
e
)
=>
handleChange
(
setting
.
key
,
e
.
target
.
value
)
}
className=
"w-full px-3 py-1.5 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)
}
</
div
>
</
div
>
);
})
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/availability/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPost
,
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
{
formatDate
}
from
'@/lib/date'
;
import
{
Calendar
,
Plus
,
Trash2
,
Loader2
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
AvailabilityPage
()
{
const
[
unavailability
,
setUnavailability
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
showForm
,
setShowForm
]
=
useState
(
false
);
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
startDate
:
''
,
endDate
:
''
,
reason
:
'PERSONAL'
,
notes
:
''
,
});
useEffect
(()
=>
{
loadData
();
},
[]);
const
loadData
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/unavailability'
,
{
limit
:
50
,
sortOrder
:
'desc'
});
setUnavailability
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load unavailability:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleSubmit
=
async
()
=>
{
if
(
!
form
.
startDate
||
!
form
.
endDate
)
{
toast
.
error
(
'Start and end dates are required.'
);
return
;
}
setIsSubmitting
(
true
);
try
{
await
apiPost
(
'/unavailability'
,
form
);
toast
.
success
(
'Unavailability logged'
);
setShowForm
(
false
);
setForm
({
startDate
:
''
,
endDate
:
''
,
reason
:
'PERSONAL'
,
notes
:
''
});
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to log unavailability'
);
}
finally
{
setIsSubmitting
(
false
);
}
};
const
handleDelete
=
async
(
id
:
string
)
=>
{
try
{
await
apiDelete
(
`/unavailability/
${
id
}
`
);
toast
.
success
(
'Unavailability removed'
);
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to remove'
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"max-w-2xl mx-auto space-y-6"
>
<
PageHeader
title=
"Availability"
description=
"Log your unavailable days"
actions=
{
<
button
onClick=
{
()
=>
setShowForm
(
!
showForm
)
}
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
}
/>
Log Unavailability
</
button
>
}
/>
{
/* Form */
}
{
showForm
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4"
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Start Date
</
label
>
<
input
type=
"date"
value=
{
form
.
startDate
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
startDate
:
e
.
target
.
value
})
}
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"
>
End Date
</
label
>
<
input
type=
"date"
value=
{
form
.
endDate
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
endDate
:
e
.
target
.
value
})
}
min=
{
form
.
startDate
}
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=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Reason
</
label
>
<
select
value=
{
form
.
reason
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
reason
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"PERSONAL"
>
Personal
</
option
>
<
option
value=
"MEDICAL"
>
Medical
</
option
>
<
option
value=
"RELIGIOUS"
>
Religious
</
option
>
<
option
value=
"EMERGENCY"
>
Emergency
</
option
>
<
option
value=
"OTHER"
>
Other
</
option
>
</
select
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Notes (optional)
</
label
>
<
textarea
value=
{
form
.
notes
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
notes
:
e
.
target
.
value
})
}
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"flex justify-end gap-2"
>
<
button
onClick=
{
()
=>
setShowForm
(
false
)
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleSubmit
}
disabled=
{
isSubmitting
}
className=
"flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{
isSubmitting
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Calendar
size=
{
14
}
/>
}
Save
</
button
>
</
div
>
</
div
>
)
}
{
/* List */
}
{
unavailability
.
length
===
0
?
(
<
EmptyState
icon=
{
Calendar
}
title=
"No unavailability logged"
description=
"Log your unavailable days to keep your team informed."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
unavailability
.
map
((
entry
)
=>
{
const
isFuture
=
new
Date
(
entry
.
startDate
)
>
new
Date
();
return
(
<
div
key=
{
entry
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
p
className=
"text-sm font-medium"
>
{
formatDate
(
entry
.
startDate
)
}
{
entry
.
endDate
!==
entry
.
startDate
&&
` — ${formatDate(entry.endDate)}`
}
</
p
>
<
div
className=
"flex items-center gap-2 mt-0.5"
>
<
span
className=
"text-xs text-muted-foreground capitalize"
>
{
entry
.
reason
?.
toLowerCase
()
||
'personal'
}
</
span
>
{
entry
.
notes
&&
(
<
span
className=
"text-xs text-muted-foreground"
>
·
{
entry
.
notes
}
</
span
>
)
}
</
div
>
</
div
>
{
isFuture
&&
(
<
button
onClick=
{
()
=>
handleDelete
(
entry
.
id
)
}
className=
"p-2 text-muted-foreground hover:text-destructive transition-colors"
>
<
Trash2
size=
{
14
}
/>
</
button
>
)
}
</
div
>
);
})
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/evaluations/[evaluationId]/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useParams
,
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatMonthYear
,
formatDateTime
}
from
'@/lib/date'
;
import
{
ArrowLeft
}
from
'lucide-react'
;
export
default
function
EvaluationDetailPage
()
{
const
{
evaluationId
}
=
useParams
<
{
evaluationId
:
string
}
>
();
const
router
=
useRouter
();
const
[
evaluation
,
setEvaluation
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
apiGet
(
`/evaluations/
${
evaluationId
}
`
)
.
then
((
res
)
=>
setEvaluation
(
res
.
data
))
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[
evaluationId
]);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
if
(
!
evaluation
)
return
<
p
className=
"text-muted-foreground p-6"
>
Evaluation not found.
</
p
>;
const
techScores
=
evaluation
.
technicalScores
as
any
;
const
profScores
=
evaluation
.
professionalScores
as
any
;
const
autoMetrics
=
evaluation
.
autoMetrics
as
any
;
return
(
<
div
className=
"max-w-3xl mx-auto space-y-6"
>
<
div
className=
"flex items-center gap-3"
>
<
button
onClick=
{
()
=>
router
.
push
(
'/evaluations'
)
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ArrowLeft
size=
{
16
}
/>
</
button
>
<
div
>
<
h1
className=
"text-xl font-bold"
>
Evaluation —
{
formatMonthYear
(
evaluation
.
month
,
evaluation
.
year
)
}
</
h1
>
{
evaluation
.
user
&&
(
<
p
className=
"text-sm text-muted-foreground"
>
{
evaluation
.
user
.
firstName
}
{
evaluation
.
user
.
lastName
}
</
p
>
)
}
</
div
>
</
div
>
{
/* Overall Score */
}
<
div
className=
"bg-card rounded-xl border p-6 text-center"
>
<
p
className=
"text-xs text-muted-foreground uppercase tracking-wider mb-1"
>
Overall Score
</
p
>
<
p
className=
"text-5xl font-bold"
>
{
evaluation
.
overallScore
!=
null
?
evaluation
.
overallScore
.
toFixed
(
1
)
:
'—'
}
</
p
>
<
p
className=
"text-sm mt-1"
>
{
evaluation
.
rating
||
'—'
}
</
p
>
<
StatusBadge
status=
{
evaluation
.
status
}
className=
"mt-2"
/>
</
div
>
{
/* Technical Scores */
}
{
techScores
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
Technical Evaluation
</
h3
>
<
p
className=
"text-xs text-muted-foreground mb-3"
>
Score:
{
evaluation
.
technicalScore
?.
toFixed
(
1
)
||
'—'
}
/5
</
p
>
<
div
className=
"space-y-3"
>
{
Object
.
entries
(
techScores
).
map
(([
key
,
val
]:
[
string
,
any
])
=>
(
<
div
key=
{
key
}
className=
"flex items-center justify-between"
>
<
span
className=
"text-sm capitalize"
>
{
key
.
replace
(
/
([
A-Z
])
/g
,
' $1'
).
trim
()
}
</
span
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"w-24 h-2 bg-muted rounded-full overflow-hidden"
>
<
div
className=
"h-full bg-primary rounded-full"
style=
{
{
width
:
`${((typeof val === 'number' ? val : val?.score || 0) / 5) * 100}%`
}
}
/>
</
div
>
<
span
className=
"text-sm font-mono w-8 text-right"
>
{
typeof
val
===
'number'
?
val
.
toFixed
(
1
)
:
(
val
?.
score
||
0
).
toFixed
(
1
)
}
</
span
>
</
div
>
</
div
>
))
}
</
div
>
{
evaluation
.
technicalNotes
&&
(
<
p
className=
"text-sm text-muted-foreground mt-3 border-t pt-3"
>
{
evaluation
.
technicalNotes
}
</
p
>
)
}
</
div
>
)
}
{
/* Professional Scores */
}
{
profScores
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
Professional Evaluation
</
h3
>
<
p
className=
"text-xs text-muted-foreground mb-3"
>
Score:
{
evaluation
.
professionalScore
?.
toFixed
(
1
)
||
'—'
}
/5
</
p
>
<
div
className=
"space-y-3"
>
{
Object
.
entries
(
profScores
).
map
(([
key
,
val
]:
[
string
,
any
])
=>
(
<
div
key=
{
key
}
className=
"flex items-center justify-between"
>
<
span
className=
"text-sm capitalize"
>
{
key
.
replace
(
/
([
A-Z
])
/g
,
' $1'
).
trim
()
}
</
span
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"w-24 h-2 bg-muted rounded-full overflow-hidden"
>
<
div
className=
"h-full bg-blue-500 rounded-full"
style=
{
{
width
:
`${((typeof val === 'number' ? val : val?.score || 0) / 5) * 100}%`
}
}
/>
</
div
>
<
span
className=
"text-sm font-mono w-8 text-right"
>
{
typeof
val
===
'number'
?
val
.
toFixed
(
1
)
:
(
val
?.
score
||
0
).
toFixed
(
1
)
}
</
span
>
</
div
>
</
div
>
))
}
</
div
>
{
evaluation
.
professionalNotes
&&
(
<
p
className=
"text-sm text-muted-foreground mt-3 border-t pt-3"
>
{
evaluation
.
professionalNotes
}
</
p
>
)
}
</
div
>
)
}
{
/* Auto Metrics */
}
{
autoMetrics
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
System Metrics
</
h3
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
{
autoMetrics
.
reportsSubmitted
!=
null
&&
(
<
MetricItem
label=
"Reports Submitted"
value=
{
`${autoMetrics.reportsSubmitted}/${autoMetrics.reportsExpected || '?'}`
}
/>
)
}
{
autoMetrics
.
onTimeRate
!=
null
&&
(
<
MetricItem
label=
"On-Time Rate"
value=
{
`${Math.round(autoMetrics.onTimeRate * 100)}%`
}
/>
)
}
{
autoMetrics
.
cardsCompleted
!=
null
&&
(
<
MetricItem
label=
"Cards Completed"
value=
{
autoMetrics
.
cardsCompleted
}
/>
)
}
{
autoMetrics
.
deadlineHitRate
!=
null
&&
(
<
MetricItem
label=
"Deadline Hit Rate"
value=
{
`${Math.round(autoMetrics.deadlineHitRate * 100)}%`
}
/>
)
}
{
autoMetrics
.
deductionCount
!=
null
&&
(
<
MetricItem
label=
"Deductions"
value=
{
autoMetrics
.
deductionCount
}
/>
)
}
{
autoMetrics
.
bountyCount
!=
null
&&
(
<
MetricItem
label=
"Bounties Earned"
value=
{
autoMetrics
.
bountyCount
}
/>
)
}
</
div
>
</
div
>
)
}
{
/* Contractor Response */
}
{
evaluation
.
contractorResponse
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-2"
>
Contractor Response
</
h3
>
<
p
className=
"text-sm text-muted-foreground whitespace-pre-wrap"
>
{
evaluation
.
contractorResponse
}
</
p
>
{
evaluation
.
respondedAt
&&
(
<
p
className=
"text-xs text-muted-foreground mt-2"
>
Responded
{
formatDateTime
(
evaluation
.
respondedAt
)
}
</
p
>
)
}
</
div
>
)
}
</
div
>
);
}
function
MetricItem
({
label
,
value
}:
{
label
:
string
;
value
:
string
|
number
})
{
return
(
<
div
className=
"flex items-center justify-between p-2 bg-muted/30 rounded-lg"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
label
}
</
span
>
<
span
className=
"text-sm font-bold"
>
{
value
}
</
span
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/evaluations/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatMonthYear
}
from
'@/lib/date'
;
import
{
Star
,
TrendingUp
}
from
'lucide-react'
;
export
default
function
EvaluationsPage
()
{
const
router
=
useRouter
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
evaluations
,
setEvaluations
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
apiGet
(
'/evaluations'
,
{
limit
:
50
,
sortOrder
:
'desc'
})
.
then
((
res
)
=>
setEvaluations
(
res
.
data
||
[]))
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[]);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
const
getRatingEmoji
=
(
score
:
number
|
null
)
=>
{
if
(
!
score
)
return
'—'
;
if
(
score
>=
4.5
)
return
'⭐'
;
if
(
score
>=
3.5
)
return
'🟢'
;
if
(
score
>=
2.5
)
return
'🟡'
;
if
(
score
>=
1.5
)
return
'🟠'
;
return
'🔴'
;
};
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Evaluations"
description=
"Monthly performance evaluations"
/>
{
evaluations
.
length
===
0
?
(
<
EmptyState
icon=
{
Star
}
title=
"No evaluations yet"
description=
"Your evaluations will appear here after the first evaluation cycle."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
evaluations
.
map
((
ev
)
=>
(
<
div
key=
{
ev
.
id
}
onClick=
{
()
=>
router
.
push
(
`/evaluations/${ev.id}`
)
}
className=
"p-4 flex items-center justify-between hover:bg-accent/50 cursor-pointer transition-colors"
>
<
div
className=
"flex items-center gap-4"
>
{
ev
.
user
&&
user
?.
role
!==
'CONTRACTOR'
&&
(
<
UserAvatar
firstName=
{
ev
.
user
.
firstName
}
lastName=
{
ev
.
user
.
lastName
}
avatar=
{
ev
.
user
.
avatar
}
size=
"sm"
/>
)
}
<
div
>
<
p
className=
"text-sm font-medium"
>
{
user
?.
role
===
'CONTRACTOR'
?
formatMonthYear
(
ev
.
month
,
ev
.
year
)
:
`${ev.user?.firstName} ${ev.user?.lastName} — ${formatMonthYear(ev.month, ev.year)}`
}
</
p
>
<
div
className=
"flex items-center gap-2 mt-0.5"
>
<
StatusBadge
status=
{
ev
.
status
}
/>
{
ev
.
rating
&&
<
span
className=
"text-xs text-muted-foreground"
>
{
ev
.
rating
}
</
span
>
}
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
{
ev
.
overallScore
!=
null
&&
(
<
div
className=
"text-right"
>
<
span
className=
"text-lg mr-1"
>
{
getRatingEmoji
(
ev
.
overallScore
)
}
</
span
>
<
span
className=
"text-lg font-bold"
>
{
ev
.
overallScore
.
toFixed
(
1
)
}
</
span
>
<
span
className=
"text-xs text-muted-foreground"
>
/5
</
span
>
</
div
>
)
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/learning/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatDate
,
daysUntil
,
isOverdue
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
GraduationCap
,
Clock
,
Target
,
AlertTriangle
}
from
'lucide-react'
;
export
default
function
LearningPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
goals
,
setGoals
]
=
useState
<
any
[]
>
([]);
const
[
competencyAreas
,
setCompetencyAreas
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
Promise
.
all
([
apiGet
(
'/learning-goals'
,
{
limit
:
50
}).
then
((
res
)
=>
setGoals
(
res
.
data
||
[])),
apiGet
(
'/learning/competency-areas'
).
then
((
res
)
=>
setCompetencyAreas
(
res
.
data
||
[])).
catch
(()
=>
{}),
]).
finally
(()
=>
setIsLoading
(
false
));
},
[]);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
const
activeGoals
=
goals
.
filter
((
g
)
=>
[
'ACTIVE'
,
'OVERDUE'
,
'EXTENDED'
].
includes
(
g
.
status
));
const
completedGoals
=
goals
.
filter
((
g
)
=>
[
'PASSED'
,
'FAILED'
].
includes
(
g
.
status
));
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Learning & Competency"
description=
"Your growth goals and skill development"
/>
{
/* Stats */
}
<
div
className=
"grid gap-4 md:grid-cols-3"
>
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-muted-foreground mb-1"
>
<
Target
size=
{
14
}
/>
<
span
className=
"text-xs font-medium uppercase"
>
Active Goals
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold"
>
{
activeGoals
.
length
}
</
p
>
</
div
>
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-emerald-500 mb-1"
>
<
GraduationCap
size=
{
14
}
/>
<
span
className=
"text-xs font-medium uppercase"
>
Completed
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold text-emerald-500"
>
{
completedGoals
.
filter
((
g
)
=>
g
.
status
===
'PASSED'
).
length
}
</
p
>
</
div
>
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-red-500 mb-1"
>
<
AlertTriangle
size=
{
14
}
/>
<
span
className=
"text-xs font-medium uppercase"
>
Overdue
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold text-red-500"
>
{
activeGoals
.
filter
((
g
)
=>
g
.
status
===
'OVERDUE'
||
(
g
.
deadline
&&
isOverdue
(
g
.
deadline
))).
length
}
</
p
>
</
div
>
</
div
>
{
/* Active Goals */
}
{
activeGoals
.
length
>
0
?
(
<
div
className=
"space-y-3"
>
<
h3
className=
"font-semibold"
>
Active Goals
</
h3
>
{
activeGoals
.
map
((
goal
)
=>
{
const
days
=
goal
.
deadline
?
daysUntil
(
goal
.
deadline
)
:
null
;
const
overdue
=
goal
.
deadline
&&
isOverdue
(
goal
.
deadline
);
return
(
<
div
key=
{
goal
.
id
}
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-start justify-between"
>
<
div
>
<
h4
className=
"text-sm font-semibold"
>
{
goal
.
title
}
</
h4
>
{
goal
.
competencyArea
&&
(
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
goal
.
competencyArea
.
name
}
</
p
>
)
}
</
div
>
<
StatusBadge
status=
{
goal
.
status
}
/>
</
div
>
{
goal
.
description
&&
(
<
p
className=
"text-sm text-muted-foreground mt-2"
>
{
goal
.
description
}
</
p
>
)
}
<
div
className=
"flex items-center gap-4 mt-3 text-xs text-muted-foreground"
>
{
goal
.
deadline
&&
(
<
span
className=
{
cn
(
'flex items-center gap-1'
,
overdue
&&
'text-red-500 font-medium'
)
}
>
<
Clock
size=
{
12
}
/>
{
overdue
?
`${Math.abs(days!)} days overdue`
:
`${days} days remaining`
}
</
span
>
)
}
{
goal
.
assessmentMethod
&&
(
<
span
>
Assessment:
{
goal
.
assessmentMethod
.
replace
(
/_/g
,
' '
)
}
</
span
>
)
}
</
div
>
</
div
>
);
})
}
</
div
>
)
:
(
<
EmptyState
icon=
{
GraduationCap
}
title=
"No active learning goals"
description=
"You don't have any active learning goals at the moment."
/>
)
}
{
/* Completed Goals */
}
{
completedGoals
.
length
>
0
&&
(
<
div
className=
"space-y-3"
>
<
h3
className=
"font-semibold"
>
Completed
</
h3
>
{
completedGoals
.
map
((
goal
)
=>
(
<
div
key=
{
goal
.
id
}
className=
"bg-card rounded-xl border p-4 opacity-75"
>
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
h4
className=
"text-sm font-medium"
>
{
goal
.
title
}
</
h4
>
{
goal
.
competencyArea
&&
(
<
p
className=
"text-xs text-muted-foreground"
>
{
goal
.
competencyArea
.
name
}
</
p
>
)
}
</
div
>
<
StatusBadge
status=
{
goal
.
status
}
/>
</
div
>
{
goal
.
assessedAt
&&
(
<
p
className=
"text-xs text-muted-foreground mt-2"
>
Assessed
{
formatDate
(
goal
.
assessedAt
)
}
</
p
>
)
}
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/meetings/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatDate
,
formatTime
,
relativeTime
,
isOverdue
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Calendar
,
Clock
,
MapPin
,
Users
}
from
'lucide-react'
;
export
default
function
MeetingsPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
meetings
,
setMeetings
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
tab
,
setTab
]
=
useState
<
'upcoming'
|
'past'
>
(
'upcoming'
);
useEffect
(()
=>
{
apiGet
(
'/meetings'
,
{
limit
:
50
,
sortOrder
:
tab
===
'upcoming'
?
'asc'
:
'desc'
})
.
then
((
res
)
=>
setMeetings
(
res
.
data
||
[]))
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[
tab
]);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
const
now
=
new
Date
();
const
upcoming
=
meetings
.
filter
((
m
)
=>
new
Date
(
m
.
startTime
)
>=
now
&&
m
.
status
===
'SCHEDULED'
);
const
past
=
meetings
.
filter
((
m
)
=>
new
Date
(
m
.
startTime
)
<
now
||
m
.
status
!==
'SCHEDULED'
);
const
displayed
=
tab
===
'upcoming'
?
upcoming
:
past
;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Meetings"
description=
"Your scheduled meetings"
/>
{
/* Tabs */
}
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
setTab
(
'upcoming'
)
}
className=
{
cn
(
'px-3 py-1.5 text-sm rounded-lg transition-colors'
,
tab
===
'upcoming'
?
'bg-accent font-medium'
:
'hover:bg-accent/50'
)
}
>
Upcoming (
{
upcoming
.
length
}
)
</
button
>
<
button
onClick=
{
()
=>
setTab
(
'past'
)
}
className=
{
cn
(
'px-3 py-1.5 text-sm rounded-lg transition-colors'
,
tab
===
'past'
?
'bg-accent font-medium'
:
'hover:bg-accent/50'
)
}
>
Past (
{
past
.
length
}
)
</
button
>
</
div
>
{
displayed
.
length
===
0
?
(
<
EmptyState
icon=
{
Calendar
}
title=
{
tab
===
'upcoming'
?
'No upcoming meetings'
:
'No past meetings'
}
description=
{
tab
===
'upcoming'
?
"You don't have any scheduled meetings."
:
'No past meetings found.'
}
/>
)
:
(
<
div
className=
"space-y-3"
>
{
displayed
.
map
((
meeting
)
=>
(
<
div
key=
{
meeting
.
id
}
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-start justify-between"
>
<
div
>
<
h3
className=
"text-sm font-semibold"
>
{
meeting
.
title
}
</
h3
>
{
meeting
.
description
&&
(
<
p
className=
"text-xs text-muted-foreground mt-1 line-clamp-2"
>
{
meeting
.
description
}
</
p
>
)
}
</
div
>
<
StatusBadge
status=
{
meeting
.
status
}
/>
</
div
>
<
div
className=
"flex items-center gap-4 mt-3 text-xs text-muted-foreground flex-wrap"
>
<
span
className=
"flex items-center gap-1"
>
<
Calendar
size=
{
12
}
/>
{
formatDate
(
meeting
.
startTime
)
}
</
span
>
<
span
className=
"flex items-center gap-1"
>
<
Clock
size=
{
12
}
/>
{
formatTime
(
meeting
.
startTime
)
}
—
{
formatTime
(
meeting
.
endTime
)
}
</
span
>
{
meeting
.
location
&&
(
<
span
className=
"flex items-center gap-1"
>
<
MapPin
size=
{
12
}
/>
{
meeting
.
location
}
</
span
>
)
}
{
meeting
.
invitees
&&
(
<
span
className=
"flex items-center gap-1"
>
<
Users
size=
{
12
}
/>
{
meeting
.
invitees
.
length
}
invitee
{
meeting
.
invitees
.
length
!==
1
?
's'
:
''
}
</
span
>
)
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/profile/page.tsx
0 → 100644
View file @
83d8f490
This diff is collapsed.
Click to expand it.
frontend/src/app/(dashboard)/reports/[reportId]/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useParams
,
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPut
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatDate
,
formatDateTime
}
from
'@/lib/date'
;
import
{
ArrowLeft
,
CheckCircle2
,
Flag
,
RotateCcw
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
ReportDetailPage
()
{
const
{
reportId
}
=
useParams
<
{
reportId
:
string
}
>
();
const
router
=
useRouter
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
report
,
setReport
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
reviewAction
,
setReviewAction
]
=
useState
(
''
);
const
[
reviewNotes
,
setReviewNotes
]
=
useState
(
''
);
const
[
isReviewing
,
setIsReviewing
]
=
useState
(
false
);
const
isReviewer
=
user
?.
role
===
'SUPER_ADMIN'
||
user
?.
role
===
'ADMIN'
||
user
?.
role
===
'TEAM_LEAD'
;
const
canReview
=
isReviewer
&&
report
&&
[
'SUBMITTED'
,
'LATE'
].
includes
(
report
.
status
);
useEffect
(()
=>
{
loadReport
();
},
[
reportId
]);
const
loadReport
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/reports/
${
reportId
}
`
);
setReport
(
res
.
data
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load report:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleReview
=
async
(
action
:
string
)
=>
{
setIsReviewing
(
true
);
try
{
await
apiPut
(
`/reports/
${
reportId
}
/review`
,
{
decision
:
action
,
reviewNotes
:
reviewNotes
||
`
${
action
}
by
${
user
?.
firstName
}
`,
});
toast.success(`
Report
$
{
action
.
toLowerCase
()}
`);
loadReport();
setReviewAction('');
setReviewNotes('');
} catch (err: any) {
toast.error(err.message || 'Failed to review report');
} finally {
setIsReviewing(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!report) return <p className="text-muted-foreground p-6">Report not found.</p>;
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/reports')} className="p-2 rounded-md hover:bg-accent">
<ArrowLeft size={16} />
</button>
<PageHeader
title={`
Daily
Report
—
$
{
report
.
reportDate
?
formatDate
(
report
.
reportDate
)
:
'Unknown'
}
`}
description={report.user ? `
$
{
report
.
user
.
firstName
}
$
{
report
.
user
.
lastName
}
` : undefined}
/>
</div>
{/* Status & Meta */}
<div className="bg-card rounded-xl border p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{report.user && (
<UserAvatar
firstName={report.user.firstName}
lastName={report.user.lastName}
avatar={report.user.avatar}
size="md"
/>
)}
<div>
<p className="text-sm font-semibold">{report.user?.firstName} {report.user?.lastName}</p>
<p className="text-xs text-muted-foreground">
Submitted {report.submittedAt ? formatDateTime(report.submittedAt) : 'N/A'}
</p>
</div>
</div>
<StatusBadge status={report.status} />
</div>
{/* Task Entries */}
<div className="bg-card rounded-xl border">
<div className="p-4 border-b">
<h3 className="font-semibold">Tasks ({report.taskEntries?.length || 0})</h3>
<p className="text-xs text-muted-foreground">Total: {report.totalHours || 0}h logged</p>
</div>
<div className="divide-y">
{(report.taskEntries || []).map((entry: any, i: number) => (
<div key={i} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{entry.cardNumber && (
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{entry.cardNumber}
</span>
)}
<StatusBadge status={entry.status} />
</div>
<span className="text-xs text-muted-foreground">
{Math.floor((entry.timeSpentMinutes || 0) / 60)}h{' '}
{(entry.timeSpentMinutes || 0) % 60 > 0 ? `
$
{(
entry
.
timeSpentMinutes
||
0
)
%
60
}
m
` : ''}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{entry.description}</p>
</div>
))}
</div>
</div>
{/* Blockers */}
{report.blockers && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Blockers</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{report.blockers}</p>
</div>
)}
{/* Additional Notes */}
{report.additionalNotes && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Additional Notes</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{report.additionalNotes}</p>
</div>
)}
{/* Review Section */}
{canReview && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">Review Actions</h3>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Review notes (optional)"
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex gap-2">
<button
onClick={() => handleReview('APPROVED')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 size={14} />
Approve
</button>
<button
onClick={() => handleReview('FLAGGED_VAGUE')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
>
<Flag size={14} />
Flag: Vague
</button>
<button
onClick={() => handleReview('REVISION_REQUESTED')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RotateCcw size={14} />
Request Revision
</button>
</div>
</div>
)}
{/* Review History */}
{report.reviewedBy && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Review</h3>
<p className="text-sm">
Reviewed by <span className="font-medium">{report.reviewedBy.firstName} {report.reviewedBy.lastName}</span>
{report.reviewedAt && <span className="text-muted-foreground"> — {formatDateTime(report.reviewedAt)}</span>}
</p>
{report.reviewNotes && (
<p className="text-sm text-muted-foreground mt-1">{report.reviewNotes}</p>
)}
</div>
)}
</div>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/reports/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatDate
,
formatShortDate
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
FileText
,
Plus
,
Search
,
Filter
,
CheckCircle2
,
Clock
,
AlertTriangle
}
from
'lucide-react'
;
export
default
function
ReportsPage
()
{
const
router
=
useRouter
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
reports
,
setReports
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
isReviewer
=
user
?.
role
===
'SUPER_ADMIN'
||
user
?.
role
===
'ADMIN'
||
user
?.
role
===
'TEAM_LEAD'
;
useEffect
(()
=>
{
loadReports
();
},
[
page
,
statusFilter
]);
const
loadReports
=
async
()
=>
{
try
{
const
params
:
any
=
{
page
,
limit
:
20
,
sortOrder
:
'desc'
};
if
(
statusFilter
)
params
.
status
=
statusFilter
;
const
res
=
await
apiGet
(
'/reports'
,
params
);
setReports
(
res
.
data
||
[]);
setTotal
(
res
.
meta
?.
total
||
0
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load reports:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Daily Reports"
description=
{
isReviewer
?
'Review and manage daily reports'
:
'Your daily check-in reports'
}
actions=
{
<
button
onClick=
{
()
=>
router
.
push
(
'/reports/submit'
)
}
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
}
/>
Submit Report
</
button
>
}
/>
{
/* Filters */
}
<
div
className=
"flex items-center gap-3 flex-wrap"
>
<
select
value=
{
statusFilter
}
onChange=
{
(
e
)
=>
{
setStatusFilter
(
e
.
target
.
value
);
setPage
(
1
);
}
}
className=
"px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
All Statuses
</
option
>
<
option
value=
"DRAFT"
>
Draft
</
option
>
<
option
value=
"SUBMITTED"
>
Submitted
</
option
>
<
option
value=
"LATE"
>
Late
</
option
>
<
option
value=
"APPROVED"
>
Approved
</
option
>
<
option
value=
"AUTO_APPROVED"
>
Auto-Approved
</
option
>
<
option
value=
"FLAGGED_VAGUE"
>
Flagged: Vague
</
option
>
<
option
value=
"FLAGGED_INCONSISTENT"
>
Flagged: Inconsistent
</
option
>
<
option
value=
"REVISION_REQUESTED"
>
Revision Requested
</
option
>
<
option
value=
"AMENDED"
>
Amended
</
option
>
</
select
>
</
div
>
{
/* Reports List */
}
{
reports
.
length
===
0
?
(
<
EmptyState
icon=
{
FileText
}
title=
"No reports found"
description=
{
statusFilter
?
'Try changing the filter.'
:
'Submit your first daily report.'
}
action=
{
<
button
onClick=
{
()
=>
router
.
push
(
'/reports/submit'
)
}
className=
"bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
Submit Report
</
button
>
}
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
reports
.
map
((
report
)
=>
(
<
div
key=
{
report
.
id
}
onClick=
{
()
=>
router
.
push
(
`/reports/${report.id}`
)
}
className=
"p-4 flex items-center justify-between hover:bg-accent/50 cursor-pointer transition-colors"
>
<
div
className=
"flex items-center gap-4"
>
{
isReviewer
&&
report
.
user
&&
(
<
UserAvatar
firstName=
{
report
.
user
.
firstName
||
'?'
}
lastName=
{
report
.
user
.
lastName
||
'?'
}
avatar=
{
report
.
user
.
avatar
}
size=
"sm"
/>
)
}
<
div
>
<
div
className=
"flex items-center gap-2"
>
<
p
className=
"text-sm font-medium"
>
{
isReviewer
&&
report
.
user
?
`${report.user.firstName} ${report.user.lastName}`
:
'Daily Report'
}
</
p
>
<
StatusBadge
status=
{
report
.
status
}
/>
</
div
>
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
report
.
reportDate
?
formatDate
(
report
.
reportDate
)
:
'No date'
}
{
report
.
totalHours
!=
null
&&
` · ${report.totalHours}h logged`
}
{
report
.
taskEntries
?.
length
>
0
&&
` · ${report.taskEntries.length} tasks`
}
</
p
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2"
>
{
report
.
status
===
'LATE'
&&
<
Clock
size=
{
14
}
className=
"text-yellow-500"
/>
}
{
report
.
status
===
'FLAGGED_VAGUE'
&&
<
AlertTriangle
size=
{
14
}
className=
"text-red-500"
/>
}
{
(
report
.
status
===
'APPROVED'
||
report
.
status
===
'AUTO_APPROVED'
)
&&
(
<
CheckCircle2
size=
{
14
}
className=
"text-emerald-500"
/>
)
}
</
div
>
</
div
>
))
}
</
div
>
)
}
{
/* Pagination */
}
{
total
>
20
&&
(
<
div
className=
"flex items-center justify-between"
>
<
span
className=
"text-xs text-muted-foreground"
>
Page
{
page
}
of
{
Math
.
ceil
(
total
/
20
)
}
</
span
>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
Math
.
max
(
1
,
p
-
1
))
}
disabled=
{
page
<=
1
}
className=
"px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Previous
</
button
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
p
+
1
)
}
disabled=
{
page
>=
Math
.
ceil
(
total
/
20
)
}
className=
"px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next
</
button
>
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/reports/submit/page.tsx
0 → 100644
View file @
83d8f490
This diff is collapsed.
Click to expand it.
frontend/src/app/(dashboard)/schedule/page.tsx
0 → 100644
View file @
83d8f490
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPost
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
Clock
,
Building
,
Home
,
X
,
Send
,
Loader2
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
const
DAY_NAMES
=
[
'sunday'
,
'monday'
,
'tuesday'
,
'wednesday'
,
'thursday'
];
const
DAY_LABELS
=
[
'Sunday'
,
'Monday'
,
'Tuesday'
,
'Wednesday'
,
'Thursday'
];
const
DAY_TYPES
=
[
{
value
:
'IN_OFFICE'
,
label
:
'In Office'
,
icon
:
Building
,
color
:
'bg-blue-500/10 text-blue-500 border-blue-500/20'
},
{
value
:
'REMOTE'
,
label
:
'Remote'
,
icon
:
Home
,
color
:
'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
},
{
value
:
'OFF'
,
label
:
'Off'
,
icon
:
X
,
color
:
'bg-muted text-muted-foreground border-border'
},
];
export
default
function
SchedulePage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
profile
,
setProfile
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
showChangeRequest
,
setShowChangeRequest
]
=
useState
(
false
);
const
[
proposedSchedule
,
setProposedSchedule
]
=
useState
<
Record
<
string
,
string
>>
({});
const
[
reason
,
setReason
]
=
useState
(
''
);
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
useEffect
(()
=>
{
apiGet
(
`/users/
${
user
?.
id
}
`)
.then((res) => {
setProfile(res.data);
const schedule = (res.data?.weeklySchedule as Record<string, string>) || {};
setProposedSchedule({ ...schedule });
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <PageLoadingSkeleton />;
if (!profile) return <p className="text-muted-foreground p-6">Failed to load schedule.</p>;
const currentSchedule = (profile.weeklySchedule as Record<string, string>) || {};
const handleRequestChange = async () => {
if (reason.length < 50) {
toast.error('Reason must be at least 50 characters.');
return;
}
setIsSubmitting(true);
try {
const effectiveDate = new Date();
effectiveDate.setDate(effectiveDate.getDate() + 7);
await apiPost('/schedules/change-request', {
proposedSchedule,
effectiveDate: effectiveDate.toISOString().split('T')[0],
reason,
});
toast.success('Schedule change request submitted');
setShowChangeRequest(false);
setReason('');
} catch (err: any) {
toast.error(err.message || 'Failed to submit request');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto space-y-6">
<PageHeader
title="My Schedule"
description="Your weekly work schedule"
actions={
user?.role === 'CONTRACTOR' && !showChangeRequest && (
<button
onClick={() => setShowChangeRequest(true)}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<Clock size={14} />
Request Change
</button>
)
}
/>
{/* Current Schedule */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-4">Current Schedule</h3>
<div className="space-y-2">
{DAY_NAMES.map((day, i) => {
const type = currentSchedule[day] || 'OFF';
const dayType = DAY_TYPES.find((d) => d.value === type) || DAY_TYPES[2];
const Icon = dayType.icon;
return (
<div key={day} className="flex items-center justify-between p-3 rounded-lg border">
<span className="text-sm font-medium w-24">{DAY_LABELS[i]}</span>
<span className={`
flex
items
-
center
gap
-
2
px
-
3
py
-
1
rounded
-
md
text
-
xs
font
-
medium
border
$
{
dayType
.
color
}
`}>
<Icon size={12} />
{dayType.label}
</span>
</div>
);
})}
</div>
<div className="mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Base Salary: <span className="font-bold text-foreground">{formatEgp(profile.baseSalaryPiasters || 0)}</span>
</p>
<p className="text-sm text-muted-foreground">
Actual Salary: <span className="font-bold text-foreground">{formatEgp(profile.actualSalaryPiasters || 0)}</span>
</p>
</div>
</div>
{/* Change Request Form */}
{showChangeRequest && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">Request Schedule Change</h3>
<p className="text-xs text-muted-foreground">
Select your proposed schedule. Changes take effect 7 days after approval.
</p>
<div className="space-y-2">
{DAY_NAMES.map((day, i) => (
<div key={day} className="flex items-center justify-between">
<span className="text-sm font-medium w-24">{DAY_LABELS[i]}</span>
<div className="flex gap-1">
{DAY_TYPES.map((dt) => {
const Icon = dt.icon;
const isSelected = proposedSchedule[day] === dt.value;
return (
<button
key={dt.value}
onClick={() => setProposedSchedule((prev) => ({ ...prev, [day]: dt.value }))}
className={`
flex
items
-
center
gap
-
1
px
-
2.5
py
-
1.5
rounded
-
md
text
-
xs
border
transition
-
colors
$
{
isSelected
?
dt
.
color
+
' font-medium'
:
'hover:bg-accent/50'
}
`}
>
<Icon size={12} />
{dt.label}
</button>
);
})}
</div>
</div>
))}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Reason for change (min 50 characters)</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why you need this schedule change..."
rows={3}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">{reason.length}/50 min characters</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowChangeRequest(false); setReason(''); }}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleRequestChange}
disabled={isSubmitting || reason.length < 50}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Submit Request
</button>
</div>
</div>
)}
</div>
);
}
\ 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