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
Hide 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
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
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
}
from
'@/lib/date'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
User
,
Phone
,
MapPin
,
Building
,
Shield
,
Calendar
,
Edit2
,
Save
,
X
,
Loader2
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
ProfilePage
()
{
const
authUser
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
profile
,
setProfile
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
isEditing
,
setIsEditing
]
=
useState
(
false
);
const
[
isSaving
,
setIsSaving
]
=
useState
(
false
);
const
[
editData
,
setEditData
]
=
useState
<
any
>
({});
useEffect
(()
=>
{
loadProfile
();
},
[]);
const
loadProfile
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/users/
${
authUser
?.
id
}
`);
setProfile(res.data);
} catch (err) {
console.error('Failed to load profile:', err);
} finally {
setIsLoading(false);
}
};
const startEditing = () => {
setEditData({
phone: profile?.phone || '',
phoneSecondary: profile?.phoneSecondary || '',
address: profile?.address || '',
emergencyContactName: profile?.emergencyContactName || '',
emergencyContactPhone: profile?.emergencyContactPhone || '',
emergencyContactRelationship: profile?.emergencyContactRelationship || '',
bankName: profile?.bankName || '',
bankAccountNumber: profile?.bankAccountNumber || '',
bankAccountHolderName: profile?.bankAccountHolderName || '',
});
setIsEditing(true);
};
const handleSave = async () => {
setIsSaving(true);
try {
await apiPut(`
/
users
/
$
{
authUser
?.
id
}
`, editData);
toast.success('Profile updated');
setIsEditing(false);
loadProfile();
} catch (err: any) {
toast.error(err.message || 'Failed to update profile');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!profile) return <p className="text-muted-foreground p-6">Failed to load profile.</p>;
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader
title="My Profile"
actions={
!isEditing ? (
<button
onClick={startEditing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<Edit2 size={14} />
Edit
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setIsEditing(false)}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
<X size={14} />
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
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"
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save
</button>
</div>
)
}
/>
{/* Header Card */}
<div className="bg-card rounded-xl border p-6 flex items-center gap-6">
<UserAvatar
firstName={profile.firstName}
lastName={profile.lastName}
avatar={profile.avatar}
size="lg"
/>
<div>
<h2 className="text-xl font-bold">{profile.firstName} {profile.lastName}</h2>
<p className="text-sm text-muted-foreground">@{profile.username}</p>
<div className="flex items-center gap-2 mt-2">
<StatusBadge status={profile.status} />
<StatusBadge status={profile.contractorType || profile.role} />
</div>
</div>
</div>
{/* Personal Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><User size={16} /> Personal Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
<InfoField label="Full Name (English)" value={`
$
{
profile
.
firstName
}
$
{
profile
.
lastName
}
`} />
<InfoField label="Full Name (Arabic)" value={profile.nameArabic || '—'} />
<InfoField label="Date of Birth" value={profile.dateOfBirth ? formatDate(profile.dateOfBirth) : '—'} />
<InfoField label="National ID" value={profile.nationalId || '••••••••••••••'} />
</div>
</div>
{/* Contact Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Phone size={16} /> Contact Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Phone" value={editData.phone} onChange={(v) => setEditData({ ...editData, phone: v })} />
<EditField label="Secondary Phone" value={editData.phoneSecondary} onChange={(v) => setEditData({ ...editData, phoneSecondary: v })} />
<EditField label="Address" value={editData.address} onChange={(v) => setEditData({ ...editData, address: v })} />
</>
) : (
<>
<InfoField label="Phone" value={profile.phone || '—'} />
<InfoField label="Secondary Phone" value={profile.phoneSecondary || '—'} />
<InfoField label="Address" value={profile.address || '—'} className="sm:col-span-2" />
</>
)}
</div>
</div>
{/* Emergency Contact */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Shield size={16} /> Emergency Contact</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Name" value={editData.emergencyContactName} onChange={(v) => setEditData({ ...editData, emergencyContactName: v })} />
<EditField label="Phone" value={editData.emergencyContactPhone} onChange={(v) => setEditData({ ...editData, emergencyContactPhone: v })} />
<EditField label="Relationship" value={editData.emergencyContactRelationship} onChange={(v) => setEditData({ ...editData, emergencyContactRelationship: v })} />
</>
) : (
<>
<InfoField label="Name" value={profile.emergencyContactName || '—'} />
<InfoField label="Phone" value={profile.emergencyContactPhone || '—'} />
<InfoField label="Relationship" value={profile.emergencyContactRelationship || '—'} />
</>
)}
</div>
</div>
{/* Bank Details */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Building size={16} /> Bank Details</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Bank Name" value={editData.bankName} onChange={(v) => setEditData({ ...editData, bankName: v })} />
<EditField label="Account Number" value={editData.bankAccountNumber} onChange={(v) => setEditData({ ...editData, bankAccountNumber: v })} />
<EditField label="Account Holder" value={editData.bankAccountHolderName} onChange={(v) => setEditData({ ...editData, bankAccountHolderName: v })} />
</>
) : (
<>
<InfoField label="Bank Name" value={profile.bankName || '—'} />
<InfoField label="Account Number" value={profile.bankAccountNumber ? '••••' + profile.bankAccountNumber.slice(-4) : '—'} />
<InfoField label="Account Holder" value={profile.bankAccountHolderName || '—'} />
</>
)}
</div>
</div>
{/* Employment Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Calendar size={16} /> Employment</h3>
<div className="grid gap-4 sm:grid-cols-2">
<InfoField label="Role" value={profile.role?.replace('_', ' ')} />
<InfoField label="Contractor Type" value={profile.contractorType?.replace('_', ' ') || '—'} />
<InfoField label="Joined" value={profile.createdAt ? formatDate(profile.createdAt) : '—'} />
<InfoField label="Activated" value={profile.activatedAt ? formatDate(profile.activatedAt) : '—'} />
{profile.actualSalaryPiasters && (
<InfoField label="Current Salary" value={formatEgp(profile.actualSalaryPiasters)} />
)}
<InfoField label="Current Streak" value={`
$
{
profile
.
currentStreak
||
0
}
days
(
Best
:
$
{
profile
.
bestStreak
||
0
})
`} />
</div>
</div>
</div>
);
}
function InfoField({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className={className}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-0.5">{value}</p>
</div>
);
}
function EditField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="space-y-1">
<label className="text-xs text-muted-foreground">{label}</label>
<input
type="text"
value={value}
onChange={(e) => onChange(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>
);
}
\ No newline at end of file
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
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPost
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
toast
}
from
'sonner'
;
import
{
Send
,
Plus
,
Trash2
,
Loader2
,
Save
}
from
'lucide-react'
;
interface
TaskEntry
{
cardId
:
string
;
cardTitle
:
string
;
description
:
string
;
timeSpentMinutes
:
number
;
status
:
string
;
}
export
default
function
SubmitReportPage
()
{
const
router
=
useRouter
();
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
const
[
myCards
,
setMyCards
]
=
useState
<
any
[]
>
([]);
const
[
reportDate
,
setReportDate
]
=
useState
(()
=>
{
const
today
=
new
Date
();
return
today
.
toISOString
().
split
(
'T'
)[
0
];
});
const
[
taskEntries
,
setTaskEntries
]
=
useState
<
TaskEntry
[]
>
([
{
cardId
:
''
,
cardTitle
:
''
,
description
:
''
,
timeSpentMinutes
:
60
,
status
:
'IN_PROGRESS'
},
]);
const
[
blockers
,
setBlockers
]
=
useState
(
''
);
const
[
additionalNotes
,
setAdditionalNotes
]
=
useState
(
''
);
const
[
mood
,
setMood
]
=
useState
(
''
);
useEffect
(()
=>
{
apiGet
(
'/cards/my-tasks'
)
.
then
((
res
)
=>
{
const
cards
:
any
[]
=
[];
if
(
res
.
data
?.
boards
)
{
for
(
const
board
of
res
.
data
.
boards
)
{
for
(
const
card
of
board
.
cards
||
[])
{
cards
.
push
({
id
:
card
.
id
,
title
:
card
.
title
,
cardNumber
:
card
.
cardNumber
,
boardName
:
board
.
board
.
name
,
});
}
}
}
setMyCards
(
cards
);
})
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[]);
const
totalHours
=
taskEntries
.
reduce
((
sum
,
t
)
=>
sum
+
t
.
timeSpentMinutes
,
0
)
/
60
;
const
hasBlockedTask
=
taskEntries
.
some
((
t
)
=>
t
.
status
===
'BLOCKED'
);
const
addTaskEntry
=
()
=>
{
setTaskEntries
((
prev
)
=>
[
...
prev
,
{
cardId
:
''
,
cardTitle
:
''
,
description
:
''
,
timeSpentMinutes
:
60
,
status
:
'IN_PROGRESS'
},
]);
};
const
removeTaskEntry
=
(
index
:
number
)
=>
{
if
(
taskEntries
.
length
<=
1
)
return
;
setTaskEntries
((
prev
)
=>
prev
.
filter
((
_
,
i
)
=>
i
!==
index
));
};
const
updateTaskEntry
=
(
index
:
number
,
field
:
keyof
TaskEntry
,
value
:
any
)
=>
{
setTaskEntries
((
prev
)
=>
prev
.
map
((
entry
,
i
)
=>
{
if
(
i
!==
index
)
return
entry
;
const
updated
=
{
...
entry
,
[
field
]:
value
};
if
(
field
===
'cardId'
)
{
const
card
=
myCards
.
find
((
c
)
=>
c
.
id
===
value
);
updated
.
cardTitle
=
card
?
`
${
card
.
cardNumber
}
:
${
card
.
title
}
`
:
''
;
}
return
updated
;
}),
);
};
const
handleSubmit
=
async
(
isDraft
:
boolean
)
=>
{
if
(
!
isDraft
)
{
if
(
taskEntries
.
some
((
t
)
=>
!
t
.
description
||
t
.
description
.
length
<
50
))
{
toast
.
error
(
'Each task description must be at least 50 characters.'
);
return
;
}
if
(
hasBlockedTask
&&
blockers
.
length
<
30
)
{
toast
.
error
(
'Blockers description must be at least 30 characters when a task is blocked.'
);
return
;
}
}
setIsSubmitting
(
true
);
try
{
await
apiPost
(
'/reports'
,
{
reportDate
,
status
:
isDraft
?
'DRAFT'
:
'SUBMITTED'
,
taskEntries
:
taskEntries
.
map
((
t
)
=>
({
cardId
:
t
.
cardId
||
undefined
,
description
:
t
.
description
,
timeSpentMinutes
:
t
.
timeSpentMinutes
,
status
:
t
.
status
,
})),
blockers
:
blockers
||
undefined
,
additionalNotes
:
additionalNotes
||
undefined
,
mood
:
mood
||
undefined
,
totalHours
,
});
toast
.
success
(
isDraft
?
'Report saved as draft'
:
'Report submitted successfully'
);
router
.
push
(
'/reports'
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to submit report'
);
}
finally
{
setIsSubmitting
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"max-w-3xl mx-auto space-y-6"
>
<
PageHeader
title=
"Submit Daily Report"
description=
"Log your work for the day"
/>
<
div
className=
"bg-card rounded-xl border p-6 space-y-6"
>
{
/* Report Date */
}
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Report Date
</
label
>
<
input
type=
"date"
value=
{
reportDate
}
onChange=
{
(
e
)
=>
setReportDate
(
e
.
target
.
value
)
}
max=
{
new
Date
().
toISOString
().
split
(
'T'
)[
0
]
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
{
/* Task Entries */
}
<
div
className=
"space-y-4"
>
<
div
className=
"flex items-center justify-between"
>
<
label
className=
"text-sm font-medium"
>
Tasks Worked On
</
label
>
<
span
className=
"text-xs text-muted-foreground"
>
Total:
{
totalHours
.
toFixed
(
1
)
}
h
{
totalHours
>
12
&&
<
span
className=
"text-yellow-500 ml-1"
>
(⚠️ Over 12h)
</
span
>
}
</
span
>
</
div
>
{
taskEntries
.
map
((
entry
,
index
)
=>
(
<
div
key=
{
index
}
className=
"bg-muted/30 rounded-lg p-4 space-y-3"
>
<
div
className=
"flex items-center justify-between"
>
<
span
className=
"text-xs font-medium text-muted-foreground"
>
Task #
{
index
+
1
}
</
span
>
{
taskEntries
.
length
>
1
&&
(
<
button
onClick=
{
()
=>
removeTaskEntry
(
index
)
}
className=
"p-1 text-muted-foreground hover:text-destructive"
>
<
Trash2
size=
{
14
}
/>
</
button
>
)
}
</
div
>
{
/* Card selector */
}
<
select
value=
{
entry
.
cardId
}
onChange=
{
(
e
)
=>
updateTaskEntry
(
index
,
'cardId'
,
e
.
target
.
value
)
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
Select a card (optional)
</
option
>
{
myCards
.
map
((
card
)
=>
(
<
option
key=
{
card
.
id
}
value=
{
card
.
id
}
>
{
card
.
cardNumber
}
:
{
card
.
title
}
(
{
card
.
boardName
}
)
</
option
>
))
}
</
select
>
{
/* Description */
}
<
textarea
value=
{
entry
.
description
}
onChange=
{
(
e
)
=>
updateTaskEntry
(
index
,
'description'
,
e
.
target
.
value
)
}
placeholder=
"What did you do on this task? (min 50 characters)"
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"
/>
<
div
className=
"flex items-center justify-between text-xs text-muted-foreground"
>
<
span
>
{
entry
.
description
.
length
}
/50 min characters
</
span
>
</
div
>
<
div
className=
"grid grid-cols-2 gap-3"
>
{
/* Time Spent */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-xs text-muted-foreground"
>
Time Spent
</
label
>
<
select
value=
{
entry
.
timeSpentMinutes
}
onChange=
{
(
e
)
=>
updateTaskEntry
(
index
,
'timeSpentMinutes'
,
Number
(
e
.
target
.
value
))
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{
Array
.
from
({
length
:
48
},
(
_
,
i
)
=>
(
i
+
1
)
*
15
).
map
((
mins
)
=>
(
<
option
key=
{
mins
}
value=
{
mins
}
>
{
Math
.
floor
(
mins
/
60
)
}
h
{
mins
%
60
>
0
?
`${mins % 60}m`
:
''
}
</
option
>
))
}
</
select
>
</
div
>
{
/* Task Status */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-xs text-muted-foreground"
>
Status
</
label
>
<
select
value=
{
entry
.
status
}
onChange=
{
(
e
)
=>
updateTaskEntry
(
index
,
'status'
,
e
.
target
.
value
)
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"IN_PROGRESS"
>
In Progress
</
option
>
<
option
value=
"COMPLETED"
>
Completed
</
option
>
<
option
value=
"BLOCKED"
>
Blocked
</
option
>
</
select
>
</
div
>
</
div
>
</
div
>
))
}
<
button
onClick=
{
addTaskEntry
}
className=
"w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground border border-dashed rounded-lg hover:bg-accent/50 transition-colors"
>
<
Plus
size=
{
14
}
/>
Add Another Task
</
button
>
</
div
>
{
/* Blockers */
}
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Blockers
{
hasBlockedTask
&&
<
span
className=
"text-red-500"
>
*
</
span
>
}
</
label
>
<
textarea
value=
{
blockers
}
onChange=
{
(
e
)
=>
setBlockers
(
e
.
target
.
value
)
}
placeholder=
{
hasBlockedTask
?
'Describe what is blocking you (min 30 characters)'
:
'Any blockers? (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
>
{
/* Additional Notes */
}
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Additional Notes
</
label
>
<
textarea
value=
{
additionalNotes
}
onChange=
{
(
e
)
=>
setAdditionalNotes
(
e
.
target
.
value
)
}
placeholder=
"Anything else to mention? (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
>
{
/* Mood */
}
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
How are you feeling? (optional)
</
label
>
<
div
className=
"flex gap-2"
>
{
[
{
value
:
'FRUSTRATED'
,
emoji
:
'😤'
,
label
:
'Frustrated'
},
{
value
:
'NEUTRAL'
,
emoji
:
'😐'
,
label
:
'Neutral'
},
{
value
:
'GOOD'
,
emoji
:
'😊'
,
label
:
'Good'
},
{
value
:
'ON_FIRE'
,
emoji
:
'🔥'
,
label
:
'On Fire'
},
].
map
((
m
)
=>
(
<
button
key=
{
m
.
value
}
onClick=
{
()
=>
setMood
(
mood
===
m
.
value
?
''
:
m
.
value
)
}
className=
{
`flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm transition-colors ${
mood === m.value ? 'bg-accent border-primary' : 'hover:bg-accent/50'
}`
}
>
<
span
className=
"text-lg"
>
{
m
.
emoji
}
</
span
>
<
span
className=
"text-xs"
>
{
m
.
label
}
</
span
>
</
button
>
))
}
</
div
>
</
div
>
{
/* Actions */
}
<
div
className=
"flex justify-end gap-3 pt-4 border-t"
>
<
button
onClick=
{
()
=>
router
.
back
()
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</
button
>
<
button
onClick=
{
()
=>
handleSubmit
(
true
)
}
disabled=
{
isSubmitting
}
className=
"flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors disabled:opacity-50"
>
<
Save
size=
{
14
}
/>
Save Draft
</
button
>
<
button
onClick=
{
()
=>
handleSubmit
(
false
)
}
disabled=
{
isSubmitting
||
taskEntries
.
length
===
0
}
className=
"flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{
isSubmitting
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Send
size=
{
14
}
/>
}
Submit Report
</
button
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
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