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
d6979653
Commit
d6979653
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 19 files via Son of Anton
parent
19c951e4
Changes
19
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
2961 additions
and
115 deletions
+2961
-115
page.tsx
frontend/src/app/(dashboard)/admin/analytics/page.tsx
+206
-0
page.tsx
frontend/src/app/(dashboard)/admin/contractors/page.tsx
+166
-0
page.tsx
frontend/src/app/(dashboard)/admin/deductions/page.tsx
+196
-0
page.tsx
frontend/src/app/(dashboard)/admin/payroll/page.tsx
+240
-0
page.tsx
frontend/src/app/(dashboard)/boards/[boardId]/page.tsx
+70
-0
page.tsx
frontend/src/app/(dashboard)/boards/new/page.tsx
+159
-0
page.tsx
frontend/src/app/(dashboard)/boards/page.tsx
+159
-0
page.tsx
...nd/src/app/(dashboard)/messages/[conversationId]/page.tsx
+173
-0
page.tsx
frontend/src/app/(dashboard)/messages/page.tsx
+171
-0
page.tsx
frontend/src/app/(dashboard)/my-tasks/page.tsx
+118
-0
page.tsx
frontend/src/app/(dashboard)/notifications/page.tsx
+150
-0
page.tsx
frontend/src/app/(dashboard)/salary/page.tsx
+177
-0
board-header.tsx
frontend/src/components/kanban/board-header.tsx
+85
-0
board.tsx
frontend/src/components/kanban/board.tsx
+107
-0
card-detail.tsx
frontend/src/components/kanban/card-detail.tsx
+416
-0
card.tsx
frontend/src/components/kanban/card.tsx
+137
-0
column.tsx
frontend/src/components/kanban/column.tsx
+162
-0
use-notifications.ts
frontend/src/hooks/use-notifications.ts
+28
-44
notification.store.ts
frontend/src/stores/notification.store.ts
+41
-71
No files found.
frontend/src/app/(dashboard)/admin/analytics/page.tsx
0 → 100644
View file @
d6979653
'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
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
Users
,
DollarSign
,
TrendingDown
,
TrendingUp
,
AlertTriangle
,
BarChart3
,
Cpu
,
HardDrive
,
Clock
,
}
from
'lucide-react'
;
export
default
function
AnalyticsPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
dashboard
,
setDashboard
]
=
useState
<
any
>
(
null
);
const
[
systemHealth
,
setSystemHealth
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
loadData
();
},
[]);
const
loadData
=
async
()
=>
{
try
{
const
[
dashRes
,
healthRes
]
=
await
Promise
.
all
([
apiGet
(
'/analytics/dashboard/super-admin'
).
catch
(()
=>
apiGet
(
'/analytics/dashboard/admin'
)),
user
?.
role
===
'SUPER_ADMIN'
?
apiGet
(
'/analytics/system-health'
).
catch
(()
=>
null
)
:
Promise
.
resolve
(
null
),
]);
setDashboard
(
dashRes
.
data
);
if
(
healthRes
)
setSystemHealth
(
healthRes
.
data
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load analytics:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
if
(
!
dashboard
)
return
<
p
className=
"text-muted-foreground p-6"
>
Failed to load analytics.
</
p
>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Analytics & Reports"
description=
"Organization overview and metrics"
/>
{
/* Top KPIs */
}
<
div
className=
"grid gap-4 md:grid-cols-2 lg:grid-cols-4"
>
<
KpiCard
icon=
{
Users
}
label=
"Active Contractors"
value=
{
dashboard
.
contractors
?.
total
||
0
}
sub=
{
`${dashboard.contractors?.fullTimers || 0} FT · ${dashboard.contractors?.interns || 0} INT`
}
/>
<
KpiCard
icon=
{
DollarSign
}
label=
"Net Expense"
value=
{
formatEgp
(
dashboard
.
financials
?.
netExpensePiasters
||
0
)
}
sub=
{
dashboard
.
financials
?.
monthOverMonthChange
?
`${dashboard.financials.monthOverMonthChange > 0 ? '+' : ''}${dashboard.financials.monthOverMonthChange}% vs last month`
:
'This month'
}
highlight=
{
dashboard
.
financials
?.
monthOverMonthChange
>
10
}
/>
<
KpiCard
icon=
{
TrendingDown
}
label=
"Deductions"
value=
{
formatEgp
(
dashboard
.
deductionsThisMonth
?.
totalPiasters
||
0
)
}
sub=
{
`${dashboard.deductionsThisMonth?.count || 0} applied`
}
/>
<
KpiCard
icon=
{
TrendingUp
}
label=
"Bounties"
value=
{
formatEgp
(
dashboard
.
bountiesThisMonth
?.
totalPiasters
||
0
)
}
sub=
{
`${dashboard.bountiesThisMonth?.count || 0} paid`
}
/>
</
div
>
{
/* Payroll & Actions */
}
<
div
className=
"grid gap-4 lg:grid-cols-2"
>
{
/* Payroll Status */
}
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
Payroll Status
</
h3
>
<
div
className=
"flex items-center gap-3"
>
<
StatusBadge
status=
{
dashboard
.
payroll
?.
status
||
'PENDING_CALCULATION'
}
/>
<
span
className=
"text-sm text-muted-foreground"
>
{
dashboard
.
payroll
?.
contractorCount
||
0
}
contractors
{
dashboard
.
payroll
?.
totalNetPiasters
?
` · Net: ${formatEgp(dashboard.payroll.totalNetPiasters)}`
:
''
}
</
span
>
</
div
>
</
div
>
{
/* Pending Actions */
}
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
Pending Actions
</
h3
>
<
div
className=
"grid grid-cols-3 gap-2"
>
<
div
className=
"bg-accent rounded-lg p-3 text-center"
>
<
p
className=
"text-2xl font-bold"
>
{
dashboard
.
pendingActions
?.
deductionReviews
||
0
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground"
>
Deduction Reviews
</
p
>
</
div
>
<
div
className=
"bg-accent rounded-lg p-3 text-center"
>
<
p
className=
"text-2xl font-bold"
>
{
dashboard
.
pendingActions
?.
adjustments
||
0
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground"
>
Adjustments
</
p
>
</
div
>
<
div
className=
"bg-accent rounded-lg p-3 text-center"
>
<
p
className=
"text-2xl font-bold"
>
{
dashboard
.
pendingActions
?.
scheduleChanges
||
0
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground"
>
Schedule Requests
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* At-Risk & Top Performers */
}
{
dashboard
.
performance
&&
(
<
div
className=
"grid gap-4 lg:grid-cols-2"
>
{
/* Top Performers */
}
{
dashboard
.
performance
.
topPerformersByEval
?.
length
>
0
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3"
>
Top Performers (Eval Score)
</
h3
>
<
div
className=
"space-y-2"
>
{
dashboard
.
performance
.
topPerformersByEval
.
map
((
p
:
any
,
i
:
number
)
=>
(
<
div
key=
{
i
}
className=
"flex items-center justify-between text-sm"
>
<
span
>
{
p
.
user
?.
firstName
}
{
p
.
user
?.
lastName
}
</
span
>
<
span
className=
"font-mono font-bold"
>
{
p
.
score
?.
toFixed
(
1
)
}
</
span
>
</
div
>
))
}
</
div
>
</
div
>
)
}
{
/* At-Risk */
}
{
dashboard
.
performance
.
atRiskContractors
?.
length
>
0
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3 flex items-center gap-2"
>
<
AlertTriangle
size=
{
16
}
className=
"text-red-500"
/>
At-Risk Contractors
</
h3
>
<
div
className=
"space-y-2"
>
{
dashboard
.
performance
.
atRiskContractors
.
map
((
c
:
any
,
i
:
number
)
=>
(
<
div
key=
{
i
}
className=
"flex items-center justify-between text-sm"
>
<
span
>
{
c
.
user
?.
firstName
}
{
c
.
user
?.
lastName
}
</
span
>
<
span
className=
"text-red-500 font-mono"
>
{
c
.
deductionPercentage
}
% deducted
</
span
>
</
div
>
))
}
</
div
>
</
div
>
)
}
</
div
>
)
}
{
/* System Health */
}
{
systemHealth
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
h3
className=
"font-semibold mb-3 flex items-center gap-2"
>
<
Cpu
size=
{
16
}
/>
System Health
</
h3
>
<
div
className=
"grid gap-4 sm:grid-cols-4"
>
<
div
>
<
p
className=
"text-xs text-muted-foreground"
>
Active Sessions
</
p
>
<
p
className=
"text-lg font-bold"
>
{
systemHealth
.
activeSessions
}
</
p
>
</
div
>
<
div
>
<
p
className=
"text-xs text-muted-foreground"
>
Errors (24h)
</
p
>
<
p
className=
"text-lg font-bold"
>
{
systemHealth
.
recentErrors24h
}
</
p
>
</
div
>
<
div
>
<
p
className=
"text-xs text-muted-foreground"
>
Storage
</
p
>
<
p
className=
"text-lg font-bold"
>
{
systemHealth
.
storage
?.
totalSizeMB
||
0
}
MB
</
p
>
</
div
>
<
div
>
<
p
className=
"text-xs text-muted-foreground"
>
Uptime
</
p
>
<
p
className=
"text-lg font-bold"
>
{
Math
.
floor
((
systemHealth
.
uptime
||
0
)
/
3600
)
}
h
</
p
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
);
}
function
KpiCard
({
icon
:
Icon
,
label
,
value
,
sub
,
highlight
,
}:
{
icon
:
React
.
ElementType
;
label
:
string
;
value
:
string
|
number
;
sub
?:
string
;
highlight
?:
boolean
;
})
{
return
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-muted-foreground mb-2"
>
<
Icon
size=
{
16
}
/>
<
span
className=
"text-xs font-medium uppercase tracking-wider"
>
{
label
}
</
span
>
</
div
>
<
p
className=
{
`text-2xl font-bold ${highlight ? 'text-red-500' : ''}`
}
>
{
value
}
</
p
>
{
sub
&&
<
p
className=
"text-xs text-muted-foreground mt-0.5"
>
{
sub
}
</
p
>
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/contractors/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
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
{
formatEgp
}
from
'@/lib/utils'
;
import
{
formatDate
}
from
'@/lib/date'
;
import
{
Search
,
UserPlus
,
Download
}
from
'lucide-react'
;
export
default
function
ContractorsPage
()
{
const
router
=
useRouter
();
const
[
contractors
,
setContractors
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
useEffect
(()
=>
{
loadContractors
();
},
[
page
,
search
,
statusFilter
]);
const
loadContractors
=
async
()
=>
{
try
{
const
params
:
any
=
{
page
,
limit
:
20
,
role
:
'CONTRACTOR'
};
if
(
search
)
params
.
search
=
search
;
if
(
statusFilter
)
params
.
status
=
statusFilter
;
const
res
=
await
apiGet
(
'/users'
,
params
);
setContractors
(
res
.
data
||
[]);
setTotal
(
res
.
meta
?.
total
||
0
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load contractors:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Contractor Management"
description=
{
`${total} contractors`
}
actions=
{
<
button
onClick=
{
()
=>
router
.
push
(
'/admin/invites'
)
}
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"
>
<
UserPlus
size=
{
16
}
/>
Invite Contractor
</
button
>
}
/>
{
/* Filters */
}
<
div
className=
"flex items-center gap-3 flex-wrap"
>
<
div
className=
"relative flex-1 max-w-sm"
>
<
Search
size=
{
16
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
placeholder=
"Search by name or username..."
value=
{
search
}
onChange=
{
(
e
)
=>
{
setSearch
(
e
.
target
.
value
);
setPage
(
1
);
}
}
className=
"w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
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=
"ACTIVE"
>
Active
</
option
>
<
option
value=
"ONBOARDING"
>
Onboarding
</
option
>
<
option
value=
"ON_PIP"
>
On PIP
</
option
>
<
option
value=
"SUSPENDED"
>
Suspended
</
option
>
<
option
value=
"OFFBOARDED"
>
Offboarded
</
option
>
</
select
>
</
div
>
{
/* Table */
}
<
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"
>
Contractor
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Type
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Status
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Salary
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Joined
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
contractors
.
map
((
c
)
=>
(
<
tr
key=
{
c
.
id
}
onClick=
{
()
=>
router
.
push
(
`/admin/contractors/${c.id}`
)
}
className=
"hover:bg-accent/50 cursor-pointer transition-colors"
>
<
td
className=
"px-4 py-3"
>
<
div
className=
"flex items-center gap-3"
>
<
UserAvatar
firstName=
{
c
.
firstName
}
lastName=
{
c
.
lastName
}
avatar=
{
c
.
avatar
}
size=
"sm"
/>
<
div
>
<
p
className=
"font-medium"
>
{
c
.
firstName
}
{
c
.
lastName
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
@
{
c
.
username
}
</
p
>
</
div
>
</
div
>
</
td
>
<
td
className=
"px-4 py-3"
>
<
StatusBadge
status=
{
c
.
contractorType
||
'UNKNOWN'
}
/>
</
td
>
<
td
className=
"px-4 py-3"
>
<
StatusBadge
status=
{
c
.
status
}
/>
</
td
>
<
td
className=
"px-4 py-3 font-mono"
>
{
c
.
actualSalaryPiasters
?
formatEgp
(
c
.
actualSalaryPiasters
)
:
'—'
}
</
td
>
<
td
className=
"px-4 py-3 text-muted-foreground"
>
{
c
.
createdAt
?
formatDate
(
c
.
createdAt
)
:
'—'
}
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
{
/* Pagination */
}
{
total
>
20
&&
(
<
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
/
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
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/deductions/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPut
}
from
'@/lib/api'
;
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
{
formatEgp
}
from
'@/lib/utils'
;
import
{
formatDate
}
from
'@/lib/date'
;
import
{
AlertTriangle
,
Plus
,
Search
,
Filter
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
DeductionsPage
()
{
const
router
=
useRouter
();
const
[
deductions
,
setDeductions
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
(
''
);
const
[
categoryFilter
,
setCategoryFilter
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
useEffect
(()
=>
{
loadDeductions
();
},
[
page
,
statusFilter
,
categoryFilter
]);
const
loadDeductions
=
async
()
=>
{
try
{
const
params
:
any
=
{
page
,
limit
:
20
};
if
(
statusFilter
)
params
.
status
=
statusFilter
;
if
(
categoryFilter
)
params
.
category
=
categoryFilter
;
const
res
=
await
apiGet
(
'/deductions'
,
params
);
setDeductions
(
res
.
data
||
[]);
setTotal
(
res
.
meta
?.
total
||
0
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load deductions:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleReview
=
async
(
id
:
string
,
decision
:
string
,
reason
?:
string
)
=>
{
try
{
await
apiPut
(
`/deductions/
${
id
}
/review`
,
{
decision
,
reviewNotes
:
reason
||
`
${
decision
}
by admin`
});
toast
.
success
(
`Deduction
${
decision
.
toLowerCase
()}
`
);
loadDeductions
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to review deduction'
);
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Deduction Management"
description=
{
`${total} deductions`
}
actions=
{
<
button
onClick=
{
()
=>
router
.
push
(
'/admin/deductions/create'
)
}
className=
"flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<
Plus
size=
{
16
}
/>
New Deduction
</
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=
"PENDING_ACKNOWLEDGMENT"
>
Pending Acknowledgment
</
option
>
<
option
value=
"PENDING_RESPONSE"
>
Pending Response
</
option
>
<
option
value=
"PENDING_ADMIN_REVIEW"
>
Pending Review
</
option
>
<
option
value=
"UPHELD"
>
Upheld
</
option
>
<
option
value=
"REDUCED"
>
Reduced
</
option
>
<
option
value=
"DISMISSED"
>
Dismissed
</
option
>
<
option
value=
"AUTO_APPLIED"
>
Auto-Applied
</
option
>
</
select
>
<
select
value=
{
categoryFilter
}
onChange=
{
(
e
)
=>
{
setCategoryFilter
(
e
.
target
.
value
);
setPage
(
1
);
}
}
className=
"px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
All Categories
</
option
>
<
option
value=
"A"
>
A — Deadline
</
option
>
<
option
value=
"B"
>
B — Reporting
</
option
>
<
option
value=
"C"
>
C — Quality
</
option
>
<
option
value=
"D"
>
D — Communication
</
option
>
</
select
>
</
div
>
{
/* Table */
}
<
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"
>
Contractor
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Category
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Amount
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Status
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Date
</
th
>
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground"
>
Actions
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
deductions
.
map
((
d
)
=>
(
<
tr
key=
{
d
.
id
}
className=
"hover:bg-accent/50 transition-colors"
>
<
td
className=
"px-4 py-3"
>
<
div
className=
"flex items-center gap-2"
>
<
UserAvatar
firstName=
{
d
.
user
?.
firstName
||
'?'
}
lastName=
{
d
.
user
?.
lastName
||
'?'
}
avatar=
{
d
.
user
?.
avatar
}
size=
"xs"
/>
<
span
className=
"text-sm"
>
{
d
.
user
?.
firstName
}
{
d
.
user
?.
lastName
}
</
span
>
</
div
>
</
td
>
<
td
className=
"px-4 py-3"
>
<
span
className=
"font-mono text-xs bg-muted px-1.5 py-0.5 rounded"
>
{
d
.
category
}{
d
.
subCategory
}
</
span
>
</
td
>
<
td
className=
"px-4 py-3 font-mono text-red-500"
>
-
{
formatEgp
(
d
.
appliedAmountPiasters
||
d
.
amountPiasters
||
0
)
}
</
td
>
<
td
className=
"px-4 py-3"
>
<
StatusBadge
status=
{
d
.
status
}
/>
</
td
>
<
td
className=
"px-4 py-3 text-muted-foreground text-xs"
>
{
d
.
violationDate
?
formatDate
(
d
.
violationDate
)
:
'—'
}
</
td
>
<
td
className=
"px-4 py-3"
>
{
d
.
status
===
'PENDING_ADMIN_REVIEW'
&&
(
<
div
className=
"flex gap-1"
>
<
button
onClick=
{
()
=>
handleReview
(
d
.
id
,
'UPHELD'
)
}
className=
"px-2 py-1 text-xs bg-red-500/10 text-red-500 rounded hover:bg-red-500/20"
>
Uphold
</
button
>
<
button
onClick=
{
()
=>
handleReview
(
d
.
id
,
'DISMISSED'
)
}
className=
"px-2 py-1 text-xs bg-emerald-500/10 text-emerald-500 rounded hover:bg-emerald-500/20"
>
Dismiss
</
button
>
</
div
>
)
}
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
{
total
>
20
&&
(
<
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
/
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
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/payroll/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPost
,
apiPut
}
from
'@/lib/api'
;
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
{
ConfirmDialog
}
from
'@/components/shared/confirm-dialog'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
formatMonthYear
}
from
'@/lib/date'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
Calculator
,
CheckCircle2
,
XCircle
,
Send
,
DollarSign
,
ChevronLeft
,
ChevronRight
,
Loader2
,
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
PayrollPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
payroll
,
setPayroll
]
=
useState
<
any
>
(
null
);
const
[
payrollLines
,
setPayrollLines
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
isCalculating
,
setIsCalculating
]
=
useState
(
false
);
const
[
month
,
setMonth
]
=
useState
(
new
Date
().
getMonth
()
+
1
);
const
[
year
,
setYear
]
=
useState
(
new
Date
().
getFullYear
());
const
[
confirmAction
,
setConfirmAction
]
=
useState
<
{
type
:
string
;
id
?:
string
}
|
null
>
(
null
);
useEffect
(()
=>
{
loadPayroll
();
},
[
month
,
year
]);
const
loadPayroll
=
async
()
=>
{
setIsLoading
(
true
);
try
{
const
res
=
await
apiGet
(
'/payroll'
,
{
month
,
year
});
const
payrolls
=
res
.
data
||
[];
if
(
payrolls
.
length
>
0
)
{
setPayroll
(
payrolls
[
0
]);
// Load lines
try
{
const
lineRes
=
await
apiGet
(
`/payroll/
${
payrolls
[
0
].
id
}
`
);
setPayrollLines
(
lineRes
.
data
?.
lines
||
[]);
}
catch
{
/* ok */
}
}
else
{
setPayroll
(
null
);
setPayrollLines
([]);
}
}
catch
(
err
)
{
console
.
error
(
'Failed to load payroll:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleCalculate
=
async
()
=>
{
setIsCalculating
(
true
);
try
{
await
apiPost
(
'/payroll/calculate'
,
{
month
,
year
});
toast
.
success
(
'Payroll calculated successfully'
);
loadPayroll
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to calculate payroll'
);
}
finally
{
setIsCalculating
(
false
);
}
};
const
handleSubmit
=
async
()
=>
{
if
(
!
payroll
)
return
;
try
{
await
apiPut
(
`/payroll/
${
payroll
.
id
}
/submit`
);
toast
.
success
(
'Payroll submitted for approval'
);
loadPayroll
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to submit payroll'
);
}
};
const
handleApprove
=
async
()
=>
{
if
(
!
payroll
)
return
;
try
{
await
apiPut
(
`/payroll/
${
payroll
.
id
}
/approve`
);
toast
.
success
(
'Payroll approved'
);
loadPayroll
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to approve payroll'
);
}
};
const
prevMonth
=
()
=>
{
if
(
month
===
1
)
{
setMonth
(
12
);
setYear
((
y
)
=>
y
-
1
);
}
else
setMonth
((
m
)
=>
m
-
1
);
};
const
nextMonth
=
()
=>
{
if
(
month
===
12
)
{
setMonth
(
1
);
setYear
((
y
)
=>
y
+
1
);
}
else
setMonth
((
m
)
=>
m
+
1
);
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Payroll Management"
description=
"Monthly payroll processing"
/>
{
/* Month Selector */
}
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center gap-4"
>
<
button
onClick=
{
prevMonth
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ChevronLeft
size=
{
16
}
/>
</
button
>
<
span
className=
"text-sm font-semibold w-40 text-center"
>
{
formatMonthYear
(
month
,
year
)
}
</
span
>
<
button
onClick=
{
nextMonth
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ChevronRight
size=
{
16
}
/>
</
button
>
</
div
>
<
div
className=
"flex gap-2"
>
{
(
!
payroll
||
payroll
.
status
===
'PENDING_CALCULATION'
||
payroll
.
status
===
'REJECTED'
)
&&
(
<
button
onClick=
{
handleCalculate
}
disabled=
{
isCalculating
}
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"
>
{
isCalculating
?
<
Loader2
size=
{
16
}
className=
"animate-spin"
/>
:
<
Calculator
size=
{
16
}
/>
}
{
isCalculating
?
'Calculating...'
:
'Calculate Payroll'
}
</
button
>
)
}
{
payroll
?.
status
===
'CALCULATED'
&&
(
<
button
onClick=
{
handleSubmit
}
className=
"flex items-center gap-2 bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700"
>
<
Send
size=
{
16
}
/>
Submit for Approval
</
button
>
)
}
{
payroll
?.
status
===
'SUBMITTED'
&&
user
?.
role
===
'SUPER_ADMIN'
&&
(
<
button
onClick=
{
handleApprove
}
className=
"flex items-center gap-2 bg-emerald-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-emerald-700"
>
<
CheckCircle2
size=
{
16
}
/>
Approve Payroll
</
button
>
)
}
</
div
>
</
div
>
{
/* Status */
}
{
payroll
&&
(
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center gap-3"
>
<
StatusBadge
status=
{
payroll
.
status
}
/>
<
span
className=
"text-sm text-muted-foreground"
>
{
payroll
.
contractorCount
||
0
}
contractors
</
span
>
</
div
>
<
div
className=
"flex items-center gap-6 text-sm"
>
<
div
>
<
span
className=
"text-muted-foreground"
>
Total Gross:
</
span
>
<
span
className=
"font-bold"
>
{
formatEgp
(
payroll
.
totalGrossPiasters
||
0
)
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-muted-foreground"
>
Total Net:
</
span
>
<
span
className=
"font-bold text-emerald-600"
>
{
formatEgp
(
payroll
.
totalNetPiasters
||
0
)
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
{
/* Lines */
}
{
payrollLines
.
length
>
0
&&
(
<
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"
>
Contractor
</
th
>
<
th
className=
"text-right px-4 py-3 font-medium text-muted-foreground"
>
Salary
</
th
>
<
th
className=
"text-right px-4 py-3 font-medium text-muted-foreground"
>
Bounties
</
th
>
<
th
className=
"text-right px-4 py-3 font-medium text-muted-foreground"
>
Deductions
</
th
>
<
th
className=
"text-right px-4 py-3 font-medium text-muted-foreground"
>
Net
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
payrollLines
.
map
((
line
:
any
)
=>
(
<
tr
key=
{
line
.
id
}
className=
"hover:bg-accent/50"
>
<
td
className=
"px-4 py-3"
>
<
div
className=
"flex items-center gap-2"
>
<
UserAvatar
firstName=
{
line
.
user
?.
firstName
||
'?'
}
lastName=
{
line
.
user
?.
lastName
||
'?'
}
size=
"xs"
/>
<
span
>
{
line
.
user
?.
firstName
}
{
line
.
user
?.
lastName
}
</
span
>
</
div
>
</
td
>
<
td
className=
"px-4 py-3 text-right font-mono"
>
{
formatEgp
(
line
.
actualSalaryPiasters
||
0
)
}
</
td
>
<
td
className=
"px-4 py-3 text-right font-mono text-emerald-500"
>
+
{
formatEgp
(
line
.
totalBountiesPiasters
||
0
)
}
</
td
>
<
td
className=
"px-4 py-3 text-right font-mono text-red-500"
>
-
{
formatEgp
(
line
.
totalDeductionsPiasters
||
0
)
}
</
td
>
<
td
className=
"px-4 py-3 text-right font-mono font-bold"
>
{
formatEgp
(
line
.
netPayablePiasters
||
0
)
}
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
</
div
>
)
}
{
!
payroll
&&
(
<
div
className=
"text-center py-16"
>
<
DollarSign
size=
{
48
}
className=
"mx-auto text-muted-foreground/30 mb-4"
/>
<
p
className=
"text-muted-foreground"
>
No payroll calculated for
{
formatMonthYear
(
month
,
year
)
}
.
</
p
>
<
button
onClick=
{
handleCalculate
}
disabled=
{
isCalculating
}
className=
"mt-4 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
Calculate Now
</
button
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/boards/[boardId]/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useEffect
,
useState
,
useCallback
}
from
'react'
;
import
{
useParams
,
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPut
}
from
'@/lib/api'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
KanbanBoard
}
from
'@/components/kanban/board'
;
import
{
BoardHeader
}
from
'@/components/kanban/board-header'
;
import
{
toast
}
from
'sonner'
;
export
default
function
BoardPage
()
{
const
{
boardId
}
=
useParams
<
{
boardId
:
string
}
>
();
const
[
board
,
setBoard
]
=
useState
<
any
>
(
null
);
const
[
cards
,
setCards
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
loadBoard
=
useCallback
(
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/boards/
${
boardId
}
`
);
setBoard
(
res
.
data
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to load board'
);
}
},
[
boardId
]);
const
loadCards
=
useCallback
(
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/cards'
,
{
boardId
,
limit
:
500
,
isArchived
:
false
});
setCards
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load cards:'
,
err
);
}
},
[
boardId
]);
useEffect
(()
=>
{
Promise
.
all
([
loadBoard
(),
loadCards
()]).
finally
(()
=>
setIsLoading
(
false
));
},
[
loadBoard
,
loadCards
]);
const
handleCardMoved
=
async
(
cardId
:
string
,
columnId
:
string
,
position
:
number
,
frozenReason
?:
string
)
=>
{
try
{
await
apiPut
(
`/cards/
${
cardId
}
/move`
,
{
columnId
,
position
,
frozenReason
});
await
loadCards
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to move card'
);
await
loadCards
();
}
};
const
handleCardCreated
=
()
=>
{
loadCards
();
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
if
(
!
board
)
return
<
div
className=
"p-6 text-muted-foreground"
>
Board not found.
</
div
>;
return
(
<
div
className=
"space-y-4 -m-6"
>
<
div
className=
"px-6 pt-6"
>
<
BoardHeader
board=
{
board
}
onRefresh=
{
loadBoard
}
/>
</
div
>
<
KanbanBoard
board=
{
board
}
cards=
{
cards
}
onCardMoved=
{
handleCardMoved
}
onCardCreated=
{
handleCardCreated
}
onRefresh=
{
loadCards
}
/>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/boards/new/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiPost
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
toast
}
from
'sonner'
;
export
default
function
NewBoardPage
()
{
const
router
=
useRouter
();
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
name
:
''
,
description
:
''
,
key
:
''
,
visibility
:
'PRIVATE'
,
allowContractorCreation
:
true
,
autoArchiveDoneCardsDays
:
30
,
});
const
generateKey
=
(
name
:
string
)
=>
{
return
name
.
toUpperCase
()
.
replace
(
/
[^
A-Z0-9
\s]
/g
,
''
)
.
trim
()
.
split
(
/
\s
+/
)
.
slice
(
0
,
3
)
.
map
((
w
)
=>
w
.
slice
(
0
,
4
))
.
join
(
''
)
.
slice
(
0
,
8
)
||
'BOARD'
;
};
const
handleNameChange
=
(
name
:
string
)
=>
{
setForm
((
prev
)
=>
({
...
prev
,
name
,
key
:
prev
.
key
||
generateKey
(
name
),
}));
};
const
handleSubmit
=
async
(
e
:
React
.
FormEvent
)
=>
{
e
.
preventDefault
();
if
(
!
form
.
name
.
trim
())
return
;
setIsSubmitting
(
true
);
try
{
const
res
=
await
apiPost
(
'/boards'
,
{
...
form
,
key
:
form
.
key
||
generateKey
(
form
.
name
),
});
toast
.
success
(
'Board created successfully'
);
router
.
push
(
`/boards/
${
res
.
data
.
id
}
`
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create board'
);
}
finally
{
setIsSubmitting
(
false
);
}
};
return
(
<
div
className=
"max-w-2xl mx-auto"
>
<
PageHeader
title=
"Create New Board"
description=
"Set up a new project board"
/>
<
form
onSubmit=
{
handleSubmit
}
className=
"bg-card rounded-xl border p-6 space-y-5"
>
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Board Name *
</
label
>
<
input
type=
"text"
value=
{
form
.
name
}
onChange=
{
(
e
)
=>
handleNameChange
(
e
.
target
.
value
)
}
placeholder=
"e.g., Game Development"
required
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-2"
>
<
label
className=
"text-sm font-medium"
>
Board Key *
</
label
>
<
input
type=
"text"
value=
{
form
.
key
}
onChange=
{
(
e
)
=>
setForm
((
prev
)
=>
({
...
prev
,
key
:
e
.
target
.
value
.
toUpperCase
().
replace
(
/
[^
A-Z0-9_
]
/g
,
''
)
}))
}
placeholder=
"PROJ"
maxLength=
{
10
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
/>
<
p
className=
"text-xs text-muted-foreground"
>
Used for card numbering (e.g.,
{
form
.
key
||
'PROJ'
}
-1)
</
p
>
</
div
>
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Description
</
label
>
<
textarea
value=
{
form
.
description
}
onChange=
{
(
e
)
=>
setForm
((
prev
)
=>
({
...
prev
,
description
:
e
.
target
.
value
}))
}
placeholder=
"What is this board for?"
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
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Visibility
</
label
>
<
select
value=
{
form
.
visibility
}
onChange=
{
(
e
)
=>
setForm
((
prev
)
=>
({
...
prev
,
visibility
:
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"
>
<
option
value=
"PRIVATE"
>
Private — Members only
</
option
>
<
option
value=
"PUBLIC"
>
Public — All users
</
option
>
</
select
>
</
div
>
<
div
className=
"space-y-2"
>
<
label
className=
"text-sm font-medium"
>
Auto-Archive Done Cards
</
label
>
<
select
value=
{
form
.
autoArchiveDoneCardsDays
}
onChange=
{
(
e
)
=>
setForm
((
prev
)
=>
({
...
prev
,
autoArchiveDoneCardsDays
:
Number
(
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"
>
<
option
value=
{
7
}
>
After 7 days
</
option
>
<
option
value=
{
14
}
>
After 14 days
</
option
>
<
option
value=
{
30
}
>
After 30 days
</
option
>
<
option
value=
{
60
}
>
After 60 days
</
option
>
<
option
value=
{
90
}
>
After 90 days
</
option
>
</
select
>
</
div
>
</
div
>
<
label
className=
"flex items-center gap-2 cursor-pointer"
>
<
input
type=
"checkbox"
checked=
{
form
.
allowContractorCreation
}
onChange=
{
(
e
)
=>
setForm
((
prev
)
=>
({
...
prev
,
allowContractorCreation
:
e
.
target
.
checked
}))
}
className=
"rounded border-border"
/>
<
span
className=
"text-sm"
>
Allow contractors to create cards in Backlog
</
span
>
</
label
>
<
div
className=
"flex justify-end gap-3 pt-4 border-t"
>
<
button
type=
"button"
onClick=
{
()
=>
router
.
back
()
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</
button
>
<
button
type=
"submit"
disabled=
{
isSubmitting
||
!
form
.
name
.
trim
()
}
className=
"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
?
'Creating...'
:
'Create Board'
}
</
button
>
</
div
>
</
form
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/boards/page.tsx
0 → 100644
View file @
d6979653
'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
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
PermissionGate
}
from
'@/components/shared/permission-gate'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
Kanban
,
Plus
,
Archive
,
Users
,
Search
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
export
default
function
BoardsPage
()
{
const
router
=
useRouter
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
boards
,
setBoards
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
showArchived
,
setShowArchived
]
=
useState
(
false
);
useEffect
(()
=>
{
loadBoards
();
},
[
showArchived
,
search
]);
const
loadBoards
=
async
()
=>
{
try
{
const
params
:
Record
<
string
,
any
>
=
{
limit
:
50
,
isArchived
:
showArchived
,
};
if
(
search
)
params
.
search
=
search
;
const
res
=
await
apiGet
(
'/boards'
,
params
);
setBoards
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load boards:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleCreateBoard
=
()
=>
{
router
.
push
(
'/boards/new'
);
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Boards"
description=
"Your project boards and workstreams"
actions=
{
<
PermissionGate
roles=
{
[
'SUPER_ADMIN'
,
'ADMIN'
]
}
>
<
button
onClick=
{
handleCreateBoard
}
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 transition-colors"
>
<
Plus
size=
{
16
}
/>
New Board
</
button
>
</
PermissionGate
>
}
/>
{
/* Filters */
}
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"relative flex-1 max-w-sm"
>
<
Search
size=
{
16
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
placeholder=
"Search boards..."
value=
{
search
}
onChange=
{
(
e
)
=>
setSearch
(
e
.
target
.
value
)
}
className=
"w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
button
onClick=
{
()
=>
setShowArchived
(
!
showArchived
)
}
className=
{
cn
(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors'
,
showArchived
?
'bg-accent text-accent-foreground'
:
'hover:bg-accent/50'
,
)
}
>
<
Archive
size=
{
14
}
/>
{
showArchived
?
'Showing Archived'
:
'Show Archived'
}
</
button
>
</
div
>
{
/* Board Grid */
}
{
boards
.
length
===
0
?
(
<
EmptyState
icon=
{
Kanban
}
title=
{
showArchived
?
'No archived boards'
:
'No boards yet'
}
description=
{
showArchived
?
'No boards have been archived.'
:
'Create your first board to start managing tasks.'
}
action=
{
!
showArchived
&&
(
<
PermissionGate
roles=
{
[
'SUPER_ADMIN'
,
'ADMIN'
]
}
>
<
button
onClick=
{
handleCreateBoard
}
className=
"bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
Create Board
</
button
>
</
PermissionGate
>
)
}
/>
)
:
(
<
div
className=
"grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{
boards
.
map
((
board
)
=>
(
<
button
key=
{
board
.
id
}
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}`
)
}
className=
"bg-card rounded-xl border p-4 text-left hover:border-primary/30 hover:shadow-md transition-all group"
>
<
div
className=
"flex items-start justify-between mb-3"
>
<
div
className=
"w-10 h-10 rounded-lg flex items-center justify-center text-lg"
style=
{
{
backgroundColor
:
board
.
color
?
`${board.color}20`
:
'hsl(var(--accent))'
}
}
>
{
board
.
icon
||
'📋'
}
</
div
>
<
span
className=
"text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"
>
{
board
.
key
}
</
span
>
</
div
>
<
h3
className=
"font-semibold text-sm group-hover:text-primary transition-colors"
>
{
board
.
name
}
</
h3
>
{
board
.
description
&&
(
<
p
className=
"text-xs text-muted-foreground mt-1 line-clamp-2"
>
{
board
.
description
}
</
p
>
)
}
<
div
className=
"flex items-center gap-3 mt-3 text-xs text-muted-foreground"
>
<
span
className=
"flex items-center gap-1"
>
<
Users
size=
{
12
}
/>
{
board
.
memberCount
||
0
}
</
span
>
{
board
.
isArchived
&&
(
<
span
className=
"text-yellow-500 flex items-center gap-1"
>
<
Archive
size=
{
12
}
/>
Archived
</
span
>
)
}
</
div
>
</
button
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/messages/[conversationId]/page.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useEffect
,
useState
,
useRef
}
from
'react'
;
import
{
useParams
,
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPost
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
relativeTime
,
formatTime
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
ArrowLeft
,
Send
,
Loader2
}
from
'lucide-react'
;
export
default
function
ConversationPage
()
{
const
{
conversationId
}
=
useParams
<
{
conversationId
:
string
}
>
();
const
router
=
useRouter
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
conversation
,
setConversation
]
=
useState
<
any
>
(
null
);
const
[
messages
,
setMessages
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
newMessage
,
setNewMessage
]
=
useState
(
''
);
const
[
isSending
,
setIsSending
]
=
useState
(
false
);
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
useEffect
(()
=>
{
loadConversation
();
const
interval
=
setInterval
(
loadMessages
,
5000
);
// Poll every 5s
return
()
=>
clearInterval
(
interval
);
},
[
conversationId
]);
useEffect
(()
=>
{
messagesEndRef
.
current
?.
scrollIntoView
({
behavior
:
'smooth'
});
},
[
messages
]);
const
loadConversation
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/conversations/
${
conversationId
}
`
);
setConversation
(
res
.
data
);
setMessages
(
res
.
data
?.
messages
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load conversation:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
loadMessages
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/conversations/
${
conversationId
}
`
);
setMessages
(
res
.
data
?.
messages
||
[]);
}
catch
{
/* ok */
}
};
const
handleSend
=
async
()
=>
{
if
(
!
newMessage
.
trim
())
return
;
setIsSending
(
true
);
try
{
await
apiPost
(
`/conversations/
${
conversationId
}
/messages`
,
{
content
:
newMessage
.
trim
(),
});
setNewMessage
(
''
);
loadMessages
();
}
catch
(
err
:
any
)
{
console
.
error
(
'Failed to send message:'
,
err
);
}
finally
{
setIsSending
(
false
);
}
};
if
(
isLoading
)
{
return
(
<
div
className=
"flex items-center justify-center h-[calc(100vh-12rem)]"
>
<
Loader2
className=
"animate-spin text-muted-foreground"
size=
{
24
}
/>
</
div
>
);
}
const
otherParticipant
=
conversation
?.
participants
?.
find
((
p
:
any
)
=>
p
.
id
!==
user
?.
id
);
return
(
<
div
className=
"flex flex-col h-[calc(100vh-8rem)] max-w-3xl mx-auto -mb-6"
>
{
/* Header */
}
<
div
className=
"flex items-center gap-3 pb-4 border-b"
>
<
button
onClick=
{
()
=>
router
.
push
(
'/messages'
)
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ArrowLeft
size=
{
16
}
/>
</
button
>
{
otherParticipant
&&
(
<>
<
UserAvatar
firstName=
{
otherParticipant
.
firstName
}
lastName=
{
otherParticipant
.
lastName
}
avatar=
{
otherParticipant
.
avatar
}
size=
"sm"
/>
<
div
>
<
p
className=
"text-sm font-semibold"
>
{
conversation
.
name
||
`${otherParticipant.firstName} ${otherParticipant.lastName}`
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground"
>
{
conversation
.
type
===
'GROUP'
?
`${conversation.participants?.length || 0} members`
:
otherParticipant
.
role
?.
replace
(
'_'
,
' '
)
}
</
p
>
</
div
>
</>
)
}
</
div
>
{
/* Messages */
}
<
div
className=
"flex-1 overflow-y-auto py-4 space-y-4"
>
{
messages
.
map
((
msg
:
any
)
=>
{
const
isOwn
=
msg
.
senderId
===
user
?.
id
;
return
(
<
div
key=
{
msg
.
id
}
className=
{
cn
(
'flex gap-2'
,
isOwn
?
'justify-end'
:
'justify-start'
)
}
>
{
!
isOwn
&&
(
<
UserAvatar
firstName=
{
msg
.
sender
?.
firstName
||
'?'
}
lastName=
{
msg
.
sender
?.
lastName
||
'?'
}
avatar=
{
msg
.
sender
?.
avatar
}
size=
"xs"
className=
"mt-1"
/>
)
}
<
div
className=
{
cn
(
'max-w-[70%]'
,
isOwn
&&
'order-first'
)
}
>
{
!
isOwn
&&
(
<
p
className=
"text-[10px] text-muted-foreground mb-0.5"
>
{
msg
.
sender
?.
firstName
}
</
p
>
)
}
<
div
className=
{
cn
(
'px-3 py-2 rounded-xl text-sm'
,
isOwn
?
'bg-primary text-primary-foreground rounded-tr-sm'
:
'bg-muted rounded-tl-sm'
,
)
}
>
<
p
className=
"whitespace-pre-wrap"
>
{
msg
.
content
}
</
p
>
</
div
>
<
p
className=
{
cn
(
'text-[9px] text-muted-foreground mt-0.5'
,
isOwn
&&
'text-right'
)
}
>
{
formatTime
(
msg
.
createdAt
)
}
</
p
>
</
div
>
</
div
>
);
})
}
<
div
ref=
{
messagesEndRef
}
/>
</
div
>
{
/* Input */
}
<
div
className=
"border-t pt-4 pb-2"
>
<
div
className=
"flex gap-2"
>
<
input
type=
"text"
value=
{
newMessage
}
onChange=
{
(
e
)
=>
setNewMessage
(
e
.
target
.
value
)
}
placeholder=
"Type a message..."
className=
"flex-1 px-4 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
/>
<
button
onClick=
{
handleSend
}
disabled=
{
!
newMessage
.
trim
()
||
isSending
}
className=
"p-2.5 bg-primary text-primary-foreground rounded-xl disabled:opacity-50 hover:bg-primary/90 transition-colors"
>
{
isSending
?
<
Loader2
size=
{
18
}
className=
"animate-spin"
/>
:
<
Send
size=
{
18
}
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/messages/page.tsx
0 → 100644
View file @
d6979653
'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
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
relativeTime
}
from
'@/lib/date'
;
import
{
truncate
,
cn
}
from
'@/lib/utils'
;
import
{
MessageSquare
,
Plus
,
Search
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
MessagesPage
()
{
const
router
=
useRouter
();
const
[
conversations
,
setConversations
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
showNewDm
,
setShowNewDm
]
=
useState
(
false
);
const
[
users
,
setUsers
]
=
useState
<
any
[]
>
([]);
const
[
userSearch
,
setUserSearch
]
=
useState
(
''
);
useEffect
(()
=>
{
loadConversations
();
},
[]);
const
loadConversations
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/conversations'
);
setConversations
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load conversations:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleStartDm
=
async
(
userId
:
string
)
=>
{
try
{
const
res
=
await
apiPost
(
'/conversations'
,
{
participantIds
:
[
userId
],
type
:
'DIRECT'
,
});
setShowNewDm
(
false
);
router
.
push
(
`/messages/
${
res
.
data
.
id
}
`
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to start conversation'
);
}
};
const
handleSearchUsers
=
async
(
query
:
string
)
=>
{
setUserSearch
(
query
);
if
(
query
.
length
<
2
)
{
setUsers
([]);
return
;
}
try
{
const
res
=
await
apiGet
(
'/users'
,
{
search
:
query
,
limit
:
10
});
setUsers
(
res
.
data
||
[]);
}
catch
{
/* ok */
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6 max-w-3xl mx-auto"
>
<
PageHeader
title=
"Messages"
description=
"Direct and group conversations"
actions=
{
<
button
onClick=
{
()
=>
setShowNewDm
(
true
)
}
className=
"flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<
Plus
size=
{
16
}
/>
New Message
</
button
>
}
/>
{
/* New DM dialog */
}
{
showNewDm
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-3"
>
<
div
className=
"relative"
>
<
Search
size=
{
16
}
className=
"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
placeholder=
"Search for a user..."
value=
{
userSearch
}
onChange=
{
(
e
)
=>
handleSearchUsers
(
e
.
target
.
value
)
}
autoFocus
className=
"w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
{
users
.
length
>
0
&&
(
<
div
className=
"space-y-1"
>
{
users
.
map
((
u
)
=>
(
<
button
key=
{
u
.
id
}
onClick=
{
()
=>
handleStartDm
(
u
.
id
)
}
className=
"w-full flex items-center gap-3 p-2 rounded-lg hover:bg-accent transition-colors text-left"
>
<
UserAvatar
firstName=
{
u
.
firstName
}
lastName=
{
u
.
lastName
}
avatar=
{
u
.
avatar
}
size=
"sm"
/>
<
div
>
<
p
className=
"text-sm font-medium"
>
{
u
.
firstName
}
{
u
.
lastName
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
@
{
u
.
username
}
</
p
>
</
div
>
</
button
>
))
}
</
div
>
)
}
<
button
onClick=
{
()
=>
{
setShowNewDm
(
false
);
setUserSearch
(
''
);
setUsers
([]);
}
}
className=
"text-xs text-muted-foreground hover:text-foreground"
>
Cancel
</
button
>
</
div
>
)
}
{
/* Conversation list */
}
{
conversations
.
length
===
0
?
(
<
EmptyState
icon=
{
MessageSquare
}
title=
"No conversations"
description=
"Start a new message to begin a conversation."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
conversations
.
map
((
conv
)
=>
{
const
other
=
conv
.
participants
?.
find
((
p
:
any
)
=>
p
.
id
!==
conv
.
currentUserId
)
||
conv
.
participants
?.[
0
];
return
(
<
button
key=
{
conv
.
id
}
onClick=
{
()
=>
router
.
push
(
`/messages/${conv.id}`
)
}
className=
{
cn
(
'w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors'
,
conv
.
unreadCount
>
0
&&
'bg-accent/20'
,
)
}
>
<
UserAvatar
firstName=
{
other
?.
firstName
||
'?'
}
lastName=
{
other
?.
lastName
||
'?'
}
avatar=
{
other
?.
avatar
}
size=
"md"
/>
<
div
className=
"flex-1 min-w-0"
>
<
div
className=
"flex items-center justify-between"
>
<
span
className=
{
cn
(
'text-sm'
,
conv
.
unreadCount
>
0
&&
'font-semibold'
)
}
>
{
conv
.
name
||
`${other?.firstName || '?'} ${other?.lastName || '?'}`
}
</
span
>
<
span
className=
"text-[10px] text-muted-foreground"
>
{
conv
.
lastMessageAt
?
relativeTime
(
conv
.
lastMessageAt
)
:
''
}
</
span
>
</
div
>
{
conv
.
lastMessagePreview
&&
(
<
p
className=
"text-xs text-muted-foreground truncate mt-0.5"
>
{
truncate
(
conv
.
lastMessagePreview
,
60
)
}
</
p
>
)
}
</
div
>
{
conv
.
unreadCount
>
0
&&
(
<
span
className=
"min-w-[20px] h-5 bg-primary text-primary-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1"
>
{
conv
.
unreadCount
}
</
span
>
)
}
</
button
>
);
})
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/my-tasks/page.tsx
0 → 100644
View file @
d6979653
'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
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatShortDate
,
isOverdue
}
from
'@/lib/date'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
import
{
ListTodo
,
Clock
,
Coins
,
AlertTriangle
}
from
'lucide-react'
;
import
Link
from
'next/link'
;
export
default
function
MyTasksPage
()
{
const
[
data
,
setData
]
=
useState
<
any
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
apiGet
(
'/cards/my-tasks'
)
.
then
((
res
)
=>
setData
(
res
.
data
))
.
catch
(
console
.
error
)
.
finally
(()
=>
setIsLoading
(
false
));
},
[]);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
if
(
!
data
||
data
.
totalCards
===
0
)
{
return
(
<
div
>
<
PageHeader
title=
"My Tasks"
description=
"Cards assigned to you across all boards"
/>
<
EmptyState
icon=
{
ListTodo
}
title=
"No tasks assigned"
description=
"You don't have any cards assigned to you yet."
/>
</
div
>
);
}
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"My Tasks"
description=
{
`${data.totalCards} cards across ${data.boards?.length || 0} boards`
}
actions=
{
data
.
totalOverdue
>
0
&&
(
<
span
className=
"flex items-center gap-1 text-sm text-red-500 font-medium"
>
<
AlertTriangle
size=
{
14
}
/>
{
data
.
totalOverdue
}
overdue
</
span
>
)
}
/>
{
data
.
boards
?.
map
((
group
:
any
)
=>
(
<
div
key=
{
group
.
board
.
id
}
className=
"space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
Link
href=
{
`/boards/${group.board.id}`
}
className=
"flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
>
<
span
>
{
group
.
board
.
name
}
</
span
>
<
span
className=
"text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"
>
{
group
.
board
.
key
}
</
span
>
</
Link
>
<
div
className=
"flex items-center gap-2 text-xs text-muted-foreground"
>
<
span
>
{
group
.
cardCount
}
cards
</
span
>
{
group
.
overdueCount
>
0
&&
(
<
span
className=
"text-red-500"
>
{
group
.
overdueCount
}
overdue
</
span
>
)
}
</
div
>
</
div
>
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
group
.
cards
.
map
((
card
:
any
)
=>
(
<
Link
key=
{
card
.
id
}
href=
{
`/boards/${group.board.id}`
}
className=
"flex items-center justify-between p-3 hover:bg-accent/50 transition-colors"
>
<
div
className=
"flex items-center gap-3 min-w-0"
>
<
StatusBadge
status=
{
card
.
columnType
}
/>
<
div
className=
"min-w-0"
>
<
p
className=
"text-sm font-medium truncate"
>
{
card
.
title
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground"
>
{
card
.
cardNumber
}
</
p
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3 ml-4 shrink-0"
>
{
card
.
bountyPiasters
>
0
&&
(
<
span
className=
"flex items-center gap-0.5 text-xs text-amber-500"
>
<
Coins
size=
{
12
}
/>
{
formatEgp
(
card
.
bountyPiasters
)
}
</
span
>
)
}
{
card
.
dueDate
&&
(
<
span
className=
{
cn
(
'flex items-center gap-0.5 text-xs'
,
card
.
isOverdue
?
'text-red-500 font-medium'
:
'text-muted-foreground'
,
)
}
>
<
Clock
size=
{
12
}
/>
{
formatShortDate
(
card
.
dueDate
)
}
</
span
>
)
}
{
card
.
priority
&&
card
.
priority
!==
'NONE'
&&
(
<
StatusBadge
status=
{
card
.
priority
}
/>
)
}
</
div
>
</
Link
>
))
}
</
div
>
</
div
>
))
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/notifications/page.tsx
0 → 100644
View file @
d6979653
'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
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
relativeTime
}
from
'@/lib/date'
;
import
{
Bell
,
Check
,
CheckCheck
,
AlertTriangle
,
Info
,
AlertCircle
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
export
default
function
NotificationsPage
()
{
const
[
notifications
,
setNotifications
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
filter
,
setFilter
]
=
useState
<
'all'
|
'unread'
>
(
'all'
);
const
{
fetchUnreadCount
}
=
useNotificationStore
();
useEffect
(()
=>
{
loadNotifications
();
},
[
filter
]);
const
loadNotifications
=
async
()
=>
{
try
{
const
params
:
any
=
{
limit
:
100
,
sortOrder
:
'desc'
};
if
(
filter
===
'unread'
)
params
.
isRead
=
false
;
const
res
=
await
apiGet
(
'/notifications'
,
params
);
setNotifications
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load notifications:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleMarkAllRead
=
async
()
=>
{
try
{
await
apiPut
(
'/notifications/read-all'
);
loadNotifications
();
fetchUnreadCount
();
}
catch
(
err
)
{
console
.
error
(
'Failed to mark all as read:'
,
err
);
}
};
const
handleMarkRead
=
async
(
id
:
string
)
=>
{
try
{
await
apiPut
(
`/notifications/
${
id
}
/read`
);
setNotifications
((
prev
)
=>
prev
.
map
((
n
)
=>
(
n
.
id
===
id
?
{
...
n
,
isRead
:
true
}
:
n
)),
);
fetchUnreadCount
();
}
catch
{
/* ok */
}
};
const
getIcon
=
(
type
:
string
)
=>
{
switch
(
type
)
{
case
'BLOCKING'
:
return
<
AlertTriangle
size=
{
16
}
className=
"text-red-500"
/>;
case
'IMPORTANT'
:
return
<
AlertCircle
size=
{
16
}
className=
"text-yellow-500"
/>;
default
:
return
<
Info
size=
{
16
}
className=
"text-blue-500"
/>;
}
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6 max-w-3xl mx-auto"
>
<
PageHeader
title=
"Notifications"
description=
"All your notifications"
actions=
{
<
button
onClick=
{
handleMarkAllRead
}
className=
"flex items-center gap-2 px-3 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<
CheckCheck
size=
{
14
}
/>
Mark all read
</
button
>
}
/>
{
/* Filter */
}
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
setFilter
(
'all'
)
}
className=
{
cn
(
'px-3 py-1.5 text-sm rounded-lg transition-colors'
,
filter
===
'all'
?
'bg-accent font-medium'
:
'hover:bg-accent/50'
,
)
}
>
All
</
button
>
<
button
onClick=
{
()
=>
setFilter
(
'unread'
)
}
className=
{
cn
(
'px-3 py-1.5 text-sm rounded-lg transition-colors'
,
filter
===
'unread'
?
'bg-accent font-medium'
:
'hover:bg-accent/50'
,
)
}
>
Unread
</
button
>
</
div
>
{
notifications
.
length
===
0
?
(
<
EmptyState
icon=
{
Bell
}
title=
"No notifications"
description=
{
filter
===
'unread'
?
"You're all caught up!"
:
'No notifications yet.'
}
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
notifications
.
map
((
notif
)
=>
(
<
div
key=
{
notif
.
id
}
className=
{
cn
(
'p-4 flex gap-3 transition-colors'
,
!
notif
.
isRead
&&
'bg-accent/30'
,
)
}
>
<
div
className=
"mt-0.5"
>
{
getIcon
(
notif
.
type
)
}
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
div
className=
"flex items-start justify-between gap-2"
>
<
h4
className=
{
cn
(
'text-sm'
,
!
notif
.
isRead
&&
'font-semibold'
)
}
>
{
notif
.
title
}
</
h4
>
<
span
className=
"text-[10px] text-muted-foreground whitespace-nowrap"
>
{
relativeTime
(
notif
.
createdAt
)
}
</
span
>
</
div
>
{
notif
.
message
&&
(
<
p
className=
"text-sm text-muted-foreground mt-0.5 line-clamp-2"
>
{
notif
.
message
}
</
p
>
)
}
{
!
notif
.
isRead
&&
(
<
button
onClick=
{
()
=>
handleMarkRead
(
notif
.
id
)
}
className=
"text-xs text-primary mt-1 hover:underline"
>
Mark as read
</
button
>
)
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/salary/page.tsx
0 → 100644
View file @
d6979653
'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
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
import
{
formatDate
,
formatMonthYear
}
from
'@/lib/date'
;
import
{
Wallet
,
TrendingDown
,
TrendingUp
,
Calendar
,
ChevronLeft
,
ChevronRight
,
}
from
'lucide-react'
;
export
default
function
SalaryPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
hudData
,
setHudData
]
=
useState
<
any
>
(
null
);
const
[
deductions
,
setDeductions
]
=
useState
<
any
[]
>
([]);
const
[
bounties
,
setBounties
]
=
useState
<
any
[]
>
([]);
const
[
adjustments
,
setAdjustments
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
month
,
setMonth
]
=
useState
(
new
Date
().
getMonth
()
+
1
);
const
[
year
,
setYear
]
=
useState
(
new
Date
().
getFullYear
());
useEffect
(()
=>
{
loadData
();
},
[
month
,
year
]);
const
loadData
=
async
()
=>
{
setIsLoading
(
true
);
try
{
const
[
hudRes
,
dedRes
,
bountyRes
,
adjRes
]
=
await
Promise
.
all
([
apiGet
(
`/salary/hud`
),
apiGet
(
'/deductions'
,
{
payrollMonth
:
month
,
payrollYear
:
year
,
limit
:
50
}),
apiGet
(
`/bounties/my`
,
{
month
,
year
}),
apiGet
(
'/adjustments'
,
{
limit
:
50
}),
]);
setHudData
(
hudRes
.
data
);
setDeductions
(
dedRes
.
data
||
[]);
setBounties
(
bountyRes
.
data
||
[]);
setAdjustments
(
adjRes
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load salary data:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
prevMonth
=
()
=>
{
if
(
month
===
1
)
{
setMonth
(
12
);
setYear
((
y
)
=>
y
-
1
);
}
else
setMonth
((
m
)
=>
m
-
1
);
};
const
nextMonth
=
()
=>
{
const
now
=
new
Date
();
if
(
year
===
now
.
getFullYear
()
&&
month
>=
now
.
getMonth
()
+
1
)
return
;
if
(
month
===
12
)
{
setMonth
(
1
);
setYear
((
y
)
=>
y
+
1
);
}
else
setMonth
((
m
)
=>
m
+
1
);
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
const
totalDeductions
=
deductions
.
filter
((
d
)
=>
[
'UPHELD'
,
'REDUCED'
,
'AUTO_APPLIED'
].
includes
(
d
.
status
))
.
reduce
((
sum
,
d
)
=>
sum
+
(
d
.
appliedAmountPiasters
||
d
.
amountPiasters
||
0
),
0
);
const
totalBounties
=
bounties
.
reduce
((
sum
,
b
)
=>
sum
+
(
b
.
amountPiasters
||
0
),
0
);
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Salary & Earnings"
description=
"Your financial overview"
/>
{
/* Month Selector */
}
<
div
className=
"flex items-center justify-center gap-4"
>
<
button
onClick=
{
prevMonth
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ChevronLeft
size=
{
16
}
/>
</
button
>
<
span
className=
"text-sm font-semibold w-40 text-center"
>
{
formatMonthYear
(
month
,
year
)
}
</
span
>
<
button
onClick=
{
nextMonth
}
className=
"p-2 rounded-md hover:bg-accent"
>
<
ChevronRight
size=
{
16
}
/>
</
button
>
</
div
>
{
/* Summary Cards */
}
<
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-2"
>
<
Wallet
size=
{
16
}
/>
<
span
className=
"text-xs font-medium uppercase tracking-wider"
>
Actual Salary
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold"
>
{
formatEgp
(
hudData
?.
actualSalaryPiasters
||
0
)
}
</
p
>
</
div
>
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-red-500 mb-2"
>
<
TrendingDown
size=
{
16
}
/>
<
span
className=
"text-xs font-medium uppercase tracking-wider"
>
Deductions
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold text-red-500"
>
-
{
formatEgp
(
totalDeductions
)
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
{
deductions
.
length
}
this month
</
p
>
</
div
>
<
div
className=
"bg-card rounded-xl border p-4"
>
<
div
className=
"flex items-center gap-2 text-emerald-500 mb-2"
>
<
TrendingUp
size=
{
16
}
/>
<
span
className=
"text-xs font-medium uppercase tracking-wider"
>
Bounties
</
span
>
</
div
>
<
p
className=
"text-2xl font-bold text-emerald-500"
>
+
{
formatEgp
(
totalBounties
)
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
{
bounties
.
length
}
earned
</
p
>
</
div
>
</
div
>
{
/* Deductions */
}
{
deductions
.
length
>
0
&&
(
<
div
className=
"bg-card rounded-xl border"
>
<
div
className=
"p-4 border-b"
>
<
h3
className=
"font-semibold flex items-center gap-2"
>
<
TrendingDown
size=
{
16
}
className=
"text-red-500"
/>
Deductions
</
h3
>
</
div
>
<
div
className=
"divide-y"
>
{
deductions
.
map
((
d
)
=>
(
<
div
key=
{
d
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"flex items-center gap-2 mb-1"
>
<
StatusBadge
status=
{
d
.
status
}
/>
<
span
className=
"text-xs text-muted-foreground"
>
{
d
.
category
}{
d
.
subCategory
}
</
span
>
</
div
>
<
p
className=
"text-sm"
>
{
d
.
description
?.
substring
(
0
,
100
)
}
...</
p
>
<
p
className=
"text-xs text-muted-foreground mt-1"
>
{
d
.
violationDate
?
formatDate
(
d
.
violationDate
)
:
''
}
</
p
>
</
div
>
<
span
className=
"text-sm font-bold text-red-500 shrink-0 ml-4"
>
-
{
formatEgp
(
d
.
appliedAmountPiasters
||
d
.
amountPiasters
||
0
)
}
</
span
>
</
div
>
))
}
</
div
>
</
div
>
)
}
{
/* Bounties */
}
{
bounties
.
length
>
0
&&
(
<
div
className=
"bg-card rounded-xl border"
>
<
div
className=
"p-4 border-b"
>
<
h3
className=
"font-semibold flex items-center gap-2"
>
<
TrendingUp
size=
{
16
}
className=
"text-emerald-500"
/>
Bounties Earned
</
h3
>
</
div
>
<
div
className=
"divide-y"
>
{
bounties
.
map
((
b
)
=>
(
<
div
key=
{
b
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
p
className=
"text-sm font-medium"
>
{
b
.
cardTitle
||
b
.
cardNumber
}
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
{
b
.
paidAt
?
formatDate
(
b
.
paidAt
)
:
''
}
{
b
.
splitPercentage
&&
b
.
splitPercentage
<
100
&&
` · ${b.splitPercentage}% split`
}
</
p
>
</
div
>
<
span
className=
"text-sm font-bold text-emerald-500 shrink-0 ml-4"
>
+
{
formatEgp
(
b
.
amountPiasters
)
}
</
span
>
</
div
>
))
}
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/kanban/board-header.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
Users
,
Settings
,
List
,
Calendar
,
Activity
,
LayoutGrid
}
from
'lucide-react'
;
import
{
PermissionGate
}
from
'@/components/shared/permission-gate'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
BoardHeaderProps
{
board
:
any
;
onRefresh
:
()
=>
void
;
}
export
function
BoardHeader
({
board
,
onRefresh
}:
BoardHeaderProps
)
{
const
router
=
useRouter
();
return
(
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"w-8 h-8 rounded-lg flex items-center justify-center text-sm"
style=
{
{
backgroundColor
:
board
.
color
?
`${board.color}20`
:
'hsl(var(--accent))'
}
}
>
{
board
.
icon
||
'📋'
}
</
div
>
<
div
>
<
h1
className=
"text-lg font-bold"
>
{
board
.
name
}
</
h1
>
{
board
.
description
&&
(
<
p
className=
"text-xs text-muted-foreground"
>
{
board
.
description
}
</
p
>
)
}
</
div
>
<
span
className=
"text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"
>
{
board
.
key
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-1"
>
<
button
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}`
)
}
className=
"p-2 rounded-md bg-accent text-accent-foreground text-sm"
title=
"Board View"
>
<
LayoutGrid
size=
{
16
}
/>
</
button
>
<
button
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}/list`
)
}
className=
"p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title=
"List View"
>
<
List
size=
{
16
}
/>
</
button
>
<
button
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}/calendar`
)
}
className=
"p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title=
"Calendar View"
>
<
Calendar
size=
{
16
}
/>
</
button
>
<
button
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}/activity`
)
}
className=
"p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title=
"Activity"
>
<
Activity
size=
{
16
}
/>
</
button
>
<
div
className=
"w-px h-6 bg-border mx-1"
/>
<
div
className=
"flex items-center gap-1 text-xs text-muted-foreground"
>
<
Users
size=
{
14
}
/>
<
span
>
{
board
.
memberCount
}
</
span
>
</
div
>
<
PermissionGate
roles=
{
[
'SUPER_ADMIN'
,
'ADMIN'
]
}
>
<
button
onClick=
{
()
=>
router
.
push
(
`/boards/${board.id}/settings`
)
}
className=
"p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors ml-1"
title=
"Board Settings"
>
<
Settings
size=
{
16
}
/>
</
button
>
</
PermissionGate
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/kanban/board.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useState
,
useMemo
}
from
'react'
;
import
{
KanbanColumn
}
from
'@/components/kanban/column'
;
import
{
CardDetailPanel
}
from
'@/components/kanban/card-detail'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
interface
KanbanBoardProps
{
board
:
any
;
cards
:
any
[];
onCardMoved
:
(
cardId
:
string
,
columnId
:
string
,
position
:
number
,
frozenReason
?:
string
)
=>
Promise
<
void
>
;
onCardCreated
:
()
=>
void
;
onRefresh
:
()
=>
void
;
}
export
function
KanbanBoard
({
board
,
cards
,
onCardMoved
,
onCardCreated
,
onRefresh
}:
KanbanBoardProps
)
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
selectedCardId
,
setSelectedCardId
]
=
useState
<
string
|
null
>
(
null
);
const
[
dragState
,
setDragState
]
=
useState
<
{
cardId
:
string
;
sourceColumnId
:
string
}
|
null
>
(
null
);
const
columns
=
useMemo
(()
=>
board
.
columns
||
[],
[
board
.
columns
]);
const
cardsByColumn
=
useMemo
(()
=>
{
const
map
:
Record
<
string
,
any
[]
>
=
{};
for
(
const
col
of
columns
)
{
map
[
col
.
id
]
=
[];
}
for
(
const
card
of
cards
)
{
if
(
map
[
card
.
columnId
])
{
map
[
card
.
columnId
].
push
(
card
);
}
}
// Sort by position
for
(
const
colId
of
Object
.
keys
(
map
))
{
map
[
colId
].
sort
((
a
,
b
)
=>
(
a
.
position
||
0
)
-
(
b
.
position
||
0
));
}
return
map
;
},
[
cards
,
columns
]);
const
handleDragStart
=
(
cardId
:
string
,
sourceColumnId
:
string
)
=>
{
setDragState
({
cardId
,
sourceColumnId
});
};
const
handleDrop
=
async
(
targetColumnId
:
string
)
=>
{
if
(
!
dragState
)
return
;
const
{
cardId
,
sourceColumnId
}
=
dragState
;
if
(
sourceColumnId
===
targetColumnId
)
{
setDragState
(
null
);
return
;
}
const
targetCol
=
columns
.
find
((
c
:
any
)
=>
c
.
id
===
targetColumnId
);
if
(
!
targetCol
)
return
;
// Check if moving to Frozen — need reason
if
(
targetCol
.
type
===
'FROZEN'
)
{
const
reason
=
prompt
(
'Why is this card frozen? (min 20 characters)'
);
if
(
!
reason
||
reason
.
length
<
20
)
{
setDragState
(
null
);
return
;
}
const
targetCards
=
cardsByColumn
[
targetColumnId
]
||
[];
const
position
=
targetCards
.
length
>
0
?
Math
.
max
(...
targetCards
.
map
((
c
)
=>
c
.
position
||
0
))
+
1
:
1
;
await
onCardMoved
(
cardId
,
targetColumnId
,
position
,
reason
);
}
else
{
// Check contractor can't move to Done
if
(
targetCol
.
type
===
'DONE'
&&
user
?.
role
===
'CONTRACTOR'
)
{
setDragState
(
null
);
return
;
}
const
targetCards
=
cardsByColumn
[
targetColumnId
]
||
[];
const
position
=
targetCards
.
length
>
0
?
Math
.
max
(...
targetCards
.
map
((
c
)
=>
c
.
position
||
0
))
+
1
:
1
;
await
onCardMoved
(
cardId
,
targetColumnId
,
position
);
}
setDragState
(
null
);
};
return
(
<>
<
div
className=
"flex gap-4 overflow-x-auto px-6 pb-6 min-h-[calc(100vh-12rem)]"
>
{
columns
.
map
((
column
:
any
)
=>
(
<
KanbanColumn
key=
{
column
.
id
}
column=
{
column
}
cards=
{
cardsByColumn
[
column
.
id
]
||
[]
}
board=
{
board
}
isDragOver=
{
false
}
onCardClick=
{
(
cardId
)
=>
setSelectedCardId
(
cardId
)
}
onCardCreated=
{
onCardCreated
}
onDragStart=
{
handleDragStart
}
onDrop=
{
()
=>
handleDrop
(
column
.
id
)
}
/>
))
}
</
div
>
{
selectedCardId
&&
(
<
CardDetailPanel
cardId=
{
selectedCardId
}
boardId=
{
board
.
id
}
onClose=
{
()
=>
setSelectedCardId
(
null
)
}
onUpdated=
{
onRefresh
}
/>
)
}
</>
);
}
\ No newline at end of file
frontend/src/components/kanban/card-detail.tsx
0 → 100644
View file @
d6979653
This diff is collapsed.
Click to expand it.
frontend/src/components/kanban/card.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
import
{
formatShortDate
,
isOverdue
}
from
'@/lib/date'
;
import
{
MessageSquare
,
Paperclip
,
CheckSquare
,
Coins
,
Clock
,
Snowflake
}
from
'lucide-react'
;
interface
KanbanCardProps
{
card
:
any
;
onClick
:
()
=>
void
;
onDragStart
:
()
=>
void
;
}
export
function
KanbanCard
({
card
,
onClick
,
onDragStart
}:
KanbanCardProps
)
{
const
cardIsOverdue
=
card
.
dueDate
&&
isOverdue
(
card
.
dueDate
)
&&
!
card
.
completedAt
;
return
(
<
div
className=
"bg-card rounded-lg border p-3 cursor-pointer hover:border-primary/30 hover:shadow-sm transition-all group"
onClick=
{
onClick
}
draggable
onDragStart=
{
(
e
)
=>
{
e
.
dataTransfer
.
effectAllowed
=
'move'
;
e
.
dataTransfer
.
setData
(
'text/plain'
,
card
.
id
);
onDragStart
();
}
}
>
{
/* Cover image */
}
{
card
.
coverImage
&&
(
<
div
className=
"rounded-md overflow-hidden mb-2 -mx-1 -mt-1"
>
<
img
src=
{
card
.
coverImage
}
alt=
""
className=
"w-full h-24 object-cover"
/>
</
div
>
)
}
{
/* Labels */
}
{
card
.
labels
?.
length
>
0
&&
(
<
div
className=
"flex flex-wrap gap-1 mb-2"
>
{
card
.
labels
.
slice
(
0
,
3
).
map
((
label
:
any
)
=>
(
<
span
key=
{
label
.
id
}
className=
"px-1.5 py-0.5 rounded text-[10px] font-medium"
style=
{
{
backgroundColor
:
`${label.color}20`
,
color
:
label
.
color
,
}
}
>
{
label
.
name
}
</
span
>
))
}
{
card
.
labels
.
length
>
3
&&
(
<
span
className=
"text-[10px] text-muted-foreground"
>
+
{
card
.
labels
.
length
-
3
}
</
span
>
)
}
</
div
>
)
}
{
/* Frozen banner */
}
{
card
.
frozenReason
&&
(
<
div
className=
"flex items-center gap-1 text-[10px] text-blue-500 bg-blue-500/10 rounded px-1.5 py-0.5 mb-2"
>
<
Snowflake
size=
{
10
}
/>
Frozen
</
div
>
)
}
{
/* Title */
}
<
p
className=
"text-sm font-medium leading-snug"
>
{
card
.
title
}
</
p
>
<
p
className=
"text-[10px] text-muted-foreground mt-0.5"
>
{
card
.
cardNumber
}
</
p
>
{
/* Metadata row */
}
<
div
className=
"flex items-center gap-2 mt-2 text-[10px] text-muted-foreground flex-wrap"
>
{
card
.
commentCount
>
0
&&
(
<
span
className=
"flex items-center gap-0.5"
>
<
MessageSquare
size=
{
10
}
/>
{
card
.
commentCount
}
</
span
>
)
}
{
card
.
attachmentCount
>
0
&&
(
<
span
className=
"flex items-center gap-0.5"
>
<
Paperclip
size=
{
10
}
/>
{
card
.
attachmentCount
}
</
span
>
)
}
{
card
.
checklistProgress
&&
(
<
span
className=
"flex items-center gap-0.5"
>
<
CheckSquare
size=
{
10
}
/>
{
card
.
checklistProgress
.
completed
}
/
{
card
.
checklistProgress
.
total
}
</
span
>
)
}
{
card
.
bountyPiasters
>
0
&&
(
<
span
className=
"flex items-center gap-0.5 text-amber-500 font-medium"
>
<
Coins
size=
{
10
}
/>
{
formatEgp
(
card
.
bountyPiasters
)
}
</
span
>
)
}
</
div
>
{
/* Footer */
}
<
div
className=
"flex items-center justify-between mt-2"
>
<
div
className=
"flex items-center gap-1"
>
{
card
.
dueDate
&&
(
<
span
className=
{
cn
(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded'
,
cardIsOverdue
?
'bg-red-500/10 text-red-500 font-medium'
:
'text-muted-foreground'
,
)
}
>
<
Clock
size=
{
10
}
/>
{
formatShortDate
(
card
.
dueDate
)
}
</
span
>
)
}
{
card
.
priority
&&
card
.
priority
!==
'NONE'
&&
(
<
StatusBadge
status=
{
card
.
priority
}
className=
"text-[10px]"
/>
)
}
</
div
>
{
card
.
assignees
?.
length
>
0
&&
(
<
div
className=
"flex -space-x-1"
>
{
card
.
assignees
.
slice
(
0
,
3
).
map
((
a
:
any
)
=>
(
<
UserAvatar
key=
{
a
.
id
}
firstName=
{
a
.
firstName
}
lastName=
{
a
.
lastName
}
avatar=
{
a
.
avatar
}
size=
"xs"
className=
"ring-2 ring-card"
/>
))
}
{
card
.
assignees
.
length
>
3
&&
(
<
span
className=
"w-6 h-6 rounded-full bg-muted text-[9px] font-bold flex items-center justify-center ring-2 ring-card"
>
+
{
card
.
assignees
.
length
-
3
}
</
span
>
)
}
</
div
>
)
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/kanban/column.tsx
0 → 100644
View file @
d6979653
'use client'
;
import
{
useState
}
from
'react'
;
import
{
KanbanCard
}
from
'@/components/kanban/card'
;
import
{
apiPost
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
Plus
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
KanbanColumnProps
{
column
:
any
;
cards
:
any
[];
board
:
any
;
isDragOver
:
boolean
;
onCardClick
:
(
cardId
:
string
)
=>
void
;
onCardCreated
:
()
=>
void
;
onDragStart
:
(
cardId
:
string
,
columnId
:
string
)
=>
void
;
onDrop
:
()
=>
void
;
}
export
function
KanbanColumn
({
column
,
cards
,
board
,
isDragOver
,
onCardClick
,
onCardCreated
,
onDragStart
,
onDrop
,
}:
KanbanColumnProps
)
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
isAdding
,
setIsAdding
]
=
useState
(
false
);
const
[
newTitle
,
setNewTitle
]
=
useState
(
''
);
const
canAdd
=
column
.
type
===
'BACKLOG'
&&
(
user
?.
role
!==
'CONTRACTOR'
||
board
.
allowContractorCreation
);
const
handleQuickAdd
=
async
()
=>
{
if
(
!
newTitle
.
trim
())
return
;
try
{
await
apiPost
(
'/cards'
,
{
boardId
:
board
.
id
,
columnId
:
column
.
id
,
title
:
newTitle
.
trim
(),
});
setNewTitle
(
''
);
setIsAdding
(
false
);
onCardCreated
();
toast
.
success
(
'Card created'
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create card'
);
}
};
const
wipDisplay
=
column
.
wipLimitTotal
?
`
${
cards
.
length
}
/
${
column
.
wipLimitTotal
}
`
:
null
;
const
isOverWip
=
column
.
wipLimitTotal
&&
cards
.
length
>=
column
.
wipLimitTotal
;
return
(
<
div
className=
{
cn
(
'flex-shrink-0 w-72 bg-muted/30 rounded-xl flex flex-col max-h-[calc(100vh-14rem)]'
,
isDragOver
&&
'ring-2 ring-primary/50'
,
)
}
onDragOver=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
dataTransfer
.
dropEffect
=
'move'
;
}
}
onDrop=
{
(
e
)
=>
{
e
.
preventDefault
();
onDrop
();
}
}
>
{
/* Column Header */
}
<
div
className=
"p-3 flex items-center justify-between"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"text-sm"
>
{
column
.
icon
||
'📂'
}
</
span
>
<
h3
className=
"text-sm font-semibold"
>
{
column
.
name
}
</
h3
>
<
span
className=
"text-xs text-muted-foreground bg-muted rounded-full px-1.5 py-0.5"
>
{
cards
.
length
}
</
span
>
</
div
>
{
wipDisplay
&&
(
<
span
className=
{
cn
(
'text-[10px] font-mono px-1.5 py-0.5 rounded'
,
isOverWip
?
'bg-red-500/10 text-red-500'
:
'bg-muted text-muted-foreground'
,
)
}
>
WIP:
{
wipDisplay
}
</
span
>
)
}
</
div
>
{
/* Cards */
}
<
div
className=
"flex-1 overflow-y-auto px-2 pb-2 space-y-2"
>
{
cards
.
map
((
card
)
=>
(
<
KanbanCard
key=
{
card
.
id
}
card=
{
card
}
onClick=
{
()
=>
onCardClick
(
card
.
id
)
}
onDragStart=
{
()
=>
onDragStart
(
card
.
id
,
column
.
id
)
}
/>
))
}
{
/* Quick Add */
}
{
canAdd
&&
(
<
div
className=
"pt-1"
>
{
isAdding
?
(
<
div
className=
"space-y-2"
>
<
textarea
value=
{
newTitle
}
onChange=
{
(
e
)
=>
setNewTitle
(
e
.
target
.
value
)
}
placeholder=
"Enter card title..."
autoFocus
rows=
{
2
}
className=
"w-full px-3 py-2 rounded-lg border bg-card text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleQuickAdd
();
}
if
(
e
.
key
===
'Escape'
)
{
setIsAdding
(
false
);
setNewTitle
(
''
);
}
}
}
/>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
handleQuickAdd
}
disabled=
{
!
newTitle
.
trim
()
}
className=
"px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
>
Add Card
</
button
>
<
button
onClick=
{
()
=>
{
setIsAdding
(
false
);
setNewTitle
(
''
);
}
}
className=
"px-3 py-1.5 text-xs rounded-md hover:bg-accent"
>
Cancel
</
button
>
</
div
>
</
div
>
)
:
(
<
button
onClick=
{
()
=>
setIsAdding
(
true
)
}
className=
"w-full flex items-center gap-1 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded-lg transition-colors"
>
<
Plus
size=
{
14
}
/>
Add a card
</
button
>
)
}
</
div
>
)
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/hooks/use-notifications.ts
View file @
d6979653
'use client'
;
import
{
useEffect
}
from
'react'
;
import
{
useEffect
}
from
'react'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
useSocket
}
from
'./use-
socket'
;
import
{
getSocket
}
from
'@/lib/
socket'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
apiGet
}
from
'@/lib/api'
;
export
function
useNotifications
()
{
export
function
useNotifications
()
{
const
{
on
}
=
useSocket
();
const
{
isAuthenticated
}
=
useAuthStore
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
{
fetchUnreadCount
,
fetchBlockingNotifications
,
addBlocking
,
setUnreadCount
}
=
const
{
setNotifications
,
addNotification
,
setUnreadCount
}
=
useNotificationStore
();
useNotificationStore
();
// Load initial notifications
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
user
)
return
;
if
(
!
isAuthenticated
)
return
;
apiGet
(
'/notifications'
,
{
limit
:
50
})
.
then
((
res
)
=>
{
if
(
res
.
data
?.
data
)
{
setNotifications
(
res
.
data
.
data
);
}
else
if
(
Array
.
isArray
(
res
.
data
))
{
setNotifications
(
res
.
data
);
}
})
.
catch
((
err
)
=>
{
console
.
error
(
'Failed to load notifications:'
,
err
);
});
// Get unread count
fetchUnreadCount
();
apiGet
(
'/notifications'
,
{
limit
:
1
})
fetchBlockingNotifications
();
.
then
((
res
)
=>
{
if
(
res
.
meta
?.
total
!==
undefined
)
{
// This is rough - ideally we'd have a dedicated unread count endpoint
}
})
.
catch
(()
=>
{});
},
[
user
,
setNotifications
,
setUnreadCount
]);
// Listen for real-time notifications
try
{
useEffect
(()
=>
{
const
socket
=
getSocket
();
if
(
!
user
)
return
;
const
unsub1
=
on
(
'notification:new'
,
(
notification
:
any
)
=>
{
socket
.
on
(
'notification:new'
,
(
data
:
any
)
=>
{
addNotification
(
notification
);
setUnreadCount
(
useNotificationStore
.
getState
().
unreadCount
+
1
);
});
});
const
unsub2
=
on
(
'notification:blocking'
,
(
notification
:
any
)
=>
{
socket
.
on
(
'notification:blocking'
,
(
data
:
any
)
=>
{
addNotification
({
...
notification
,
isBlocking
:
true
});
addBlocking
({
id
:
data
.
id
,
title
:
data
.
title
,
message
:
data
.
message
,
type
:
data
.
type
,
});
});
});
return
()
=>
{
return
()
=>
{
unsub1
(
);
socket
.
off
(
'notification:new'
);
unsub2
(
);
socket
.
off
(
'notification:blocking'
);
};
};
},
[
user
,
on
,
addNotification
]);
}
catch
{
// Socket not available
}
},
[
isAuthenticated
]);
}
}
\ No newline at end of file
frontend/src/stores/notification.store.ts
View file @
d6979653
import
{
create
}
from
'zustand'
;
import
{
create
}
from
'zustand'
;
import
{
apiGet
}
from
'@/lib/api'
;
interface
Notification
{
interface
Blocking
Notification
{
id
:
string
;
id
:
string
;
type
:
'BLOCKING'
|
'IMPORTANT'
|
'INFORMATIONAL'
;
category
:
string
;
title
:
string
;
title
:
string
;
message
:
string
;
message
:
string
;
actionUrl
?:
string
;
type
:
string
;
isRead
:
boolean
;
isBlocking
:
boolean
;
acknowledgedAt
:
string
|
null
;
entityType
?:
string
;
entityId
?:
string
;
createdAt
:
string
;
}
}
interface
NotificationState
{
interface
NotificationState
{
notifications
:
Notification
[];
unreadCount
:
number
;
unreadCount
:
number
;
blockingQueue
:
Notification
[];
blockingQueue
:
BlockingNotification
[];
isDropdownOpen
:
boolean
;
setNotifications
:
(
notifications
:
Notification
[])
=>
void
;
addNotification
:
(
notification
:
Notification
)
=>
void
;
markAsRead
:
(
id
:
string
)
=>
void
;
markAllAsRead
:
()
=>
void
;
acknowledgeBlocking
:
(
id
:
string
)
=>
void
;
setUnreadCount
:
(
count
:
number
)
=>
void
;
setUnreadCount
:
(
count
:
number
)
=>
void
;
toggleDropdown
:
()
=>
void
;
addBlocking
:
(
notification
:
BlockingNotification
)
=>
void
;
closeDropdown
:
()
=>
void
;
acknowledgeBlocking
:
(
id
:
string
)
=>
void
;
reset
:
()
=>
void
;
fetchUnreadCount
:
()
=>
Promise
<
void
>
;
fetchBlockingNotifications
:
()
=>
Promise
<
void
>
;
}
}
export
const
useNotificationStore
=
create
<
NotificationState
>
((
set
,
get
)
=>
({
export
const
useNotificationStore
=
create
<
NotificationState
>
((
set
,
get
)
=>
({
notifications
:
[],
unreadCount
:
0
,
unreadCount
:
0
,
blockingQueue
:
[],
blockingQueue
:
[],
isDropdownOpen
:
false
,
setNotifications
:
(
notifications
)
=>
{
setUnreadCount
:
(
count
)
=>
set
({
unreadCount
:
count
}),
const
unreadCount
=
notifications
.
filter
((
n
)
=>
!
n
.
isRead
).
length
;
const
blockingQueue
=
notifications
.
filter
(
(
n
)
=>
n
.
isBlocking
&&
!
n
.
acknowledgedAt
,
);
set
({
notifications
,
unreadCount
,
blockingQueue
});
},
add
Notification
:
(
notification
)
=>
{
add
Blocking
:
(
notification
)
=>
set
((
state
)
=>
{
set
((
state
)
=>
{
const
notifications
=
[
notification
,
...
state
.
notifications
];
const
exists
=
state
.
blockingQueue
.
some
((
n
)
=>
n
.
id
===
notification
.
id
);
const
unreadCount
=
state
.
unreadCount
+
(
notification
.
isRead
?
0
:
1
);
if
(
exists
)
return
state
;
const
blockingQueue
=
return
{
blockingQueue
:
[...
state
.
blockingQueue
,
notification
]
};
notification
.
isBlocking
&&
!
notification
.
acknowledgedAt
}),
?
[...
state
.
blockingQueue
,
notification
]
:
state
.
blockingQueue
;
return
{
notifications
,
unreadCount
,
blockingQueue
};
});
},
markAsRead
:
(
id
)
=>
{
acknowledgeBlocking
:
(
id
)
=>
set
((
state
)
=>
({
set
((
state
)
=>
({
notifications
:
state
.
notifications
.
map
((
n
)
=>
blockingQueue
:
state
.
blockingQueue
.
filter
((
n
)
=>
n
.
id
!==
id
),
n
.
id
===
id
?
{
...
n
,
isRead
:
true
}
:
n
,
})),
),
unreadCount
:
Math
.
max
(
0
,
state
.
unreadCount
-
1
),
}));
},
markAllAsRead
:
()
=>
{
fetchUnreadCount
:
async
()
=>
{
set
((
state
)
=>
({
try
{
notifications
:
state
.
notifications
.
map
((
n
)
=>
({
...
n
,
isRead
:
true
})),
const
res
=
await
apiGet
(
'/notifications'
,
{
isRead
:
false
,
limit
:
1
});
unreadCount
:
0
,
set
({
unreadCount
:
res
.
meta
?.
total
||
0
});
}));
}
catch
{
// silent
}
},
},
acknowledgeBlocking
:
(
id
)
=>
{
fetchBlockingNotifications
:
async
()
=>
{
set
((
state
)
=>
({
try
{
blockingQueue
:
state
.
blockingQueue
.
filter
((
n
)
=>
n
.
id
!==
id
),
const
res
=
await
apiGet
(
'/notifications'
,
{
notifications
:
state
.
notifications
.
map
((
n
)
=>
isBlocking
:
true
,
n
.
id
===
id
?
{
...
n
,
acknowledgedAt
:
new
Date
().
toISOString
(),
isRead
:
true
}
:
n
,
isAcknowledged
:
false
,
),
limit
:
10
,
});
const
blocking
=
(
res
.
data
||
[]).
map
((
n
:
any
)
=>
({
id
:
n
.
id
,
title
:
n
.
title
,
message
:
n
.
message
,
type
:
n
.
type
,
}));
}));
set
({
blockingQueue
:
blocking
});
}
catch
{
// silent
}
},
},
setUnreadCount
:
(
count
)
=>
set
({
unreadCount
:
count
}),
toggleDropdown
:
()
=>
set
((
state
)
=>
({
isDropdownOpen
:
!
state
.
isDropdownOpen
})),
closeDropdown
:
()
=>
set
({
isDropdownOpen
:
false
}),
reset
:
()
=>
set
({
notifications
:
[],
unreadCount
:
0
,
blockingQueue
:
[],
isDropdownOpen
:
false
,
}),
}));
}));
\ 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