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
553c8c4a
Commit
553c8c4a
authored
Apr 02, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 14 files via Son of Anton
parent
524d1e1e
Changes
14
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
1887 additions
and
0 deletions
+1887
-0
layout.tsx
frontend/src/app/(dashboard)/admin/layout.tsx
+89
-0
page.tsx
frontend/src/app/(dashboard)/reports/review/page.tsx
+346
-0
data-table-pagination.tsx
frontend/src/components/data-table/data-table-pagination.tsx
+48
-0
data-table.tsx
frontend/src/components/data-table/data-table.tsx
+237
-0
competency-radar.tsx
frontend/src/components/evaluations/competency-radar.tsx
+229
-0
rich-text-editor.tsx
frontend/src/components/forms/rich-text-editor.tsx
+169
-0
theme-toggle.tsx
frontend/src/components/layout/theme-toggle.tsx
+47
-0
notification-bell.tsx
frontend/src/components/notifications/notification-bell.tsx
+49
-0
notification-dropdown.tsx
...nd/src/components/notifications/notification-dropdown.tsx
+167
-0
relative-time.tsx
frontend/src/components/shared/relative-time.tsx
+40
-0
use-board.ts
frontend/src/hooks/use-board.ts
+86
-0
use-messages.ts
frontend/src/hooks/use-messages.ts
+101
-0
board.store.ts
frontend/src/stores/board.store.ts
+110
-0
message.store.ts
frontend/src/stores/message.store.ts
+169
-0
No files found.
frontend/src/app/(dashboard)/admin/layout.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
useRouter
,
usePathname
}
from
'next/navigation'
;
import
{
useEffect
}
from
'react'
;
import
{
Users
,
UserPlus
,
ClipboardList
,
AlertTriangle
,
DollarSign
,
Star
,
Calendar
,
Bell
,
BookOpen
,
Shield
,
Settings
,
Webhook
,
Key
,
BarChart3
,
Database
,
FileText
,
Layers
,
Cpu
,
Coins
,
FileCheck
,
}
from
'lucide-react'
;
import
Link
from
'next/link'
;
import
{
cn
}
from
'@/lib/utils'
;
const
adminLinks
=
[
{
href
:
'/admin/contractors'
,
label
:
'Contractors'
,
icon
:
Users
},
{
href
:
'/admin/invites'
,
label
:
'Invites'
,
icon
:
UserPlus
},
{
href
:
'/admin/onboarding'
,
label
:
'Onboarding'
,
icon
:
ClipboardList
},
{
href
:
'/admin/deductions'
,
label
:
'Deductions'
,
icon
:
AlertTriangle
},
{
href
:
'/admin/adjustments'
,
label
:
'Adjustments'
,
icon
:
DollarSign
},
{
href
:
'/admin/bounties'
,
label
:
'Bounties'
,
icon
:
Coins
},
{
href
:
'/admin/payroll'
,
label
:
'Payroll'
,
icon
:
FileCheck
},
{
href
:
'/admin/evaluations'
,
label
:
'Evaluations'
,
icon
:
Star
},
{
href
:
'/admin/pips'
,
label
:
'PIPs'
,
icon
:
AlertTriangle
},
{
href
:
'/admin/contracts'
,
label
:
'Contracts'
,
icon
:
FileText
},
{
href
:
'/admin/holidays'
,
label
:
'Holidays'
,
icon
:
Calendar
},
{
href
:
'/admin/notices'
,
label
:
'Notices'
,
icon
:
Bell
},
{
href
:
'/admin/policies'
,
label
:
'Policies'
,
icon
:
BookOpen
},
{
href
:
'/admin/templates'
,
label
:
'Templates'
,
icon
:
Layers
},
{
href
:
'/admin/analytics'
,
label
:
'Analytics'
,
icon
:
BarChart3
},
{
href
:
'/admin/audit-trail'
,
label
:
'Audit Trail'
,
icon
:
Shield
},
];
const
superAdminLinks
=
[
{
href
:
'/admin/settings'
,
label
:
'Settings'
,
icon
:
Settings
},
{
href
:
'/admin/api-keys'
,
label
:
'API Keys'
,
icon
:
Key
},
{
href
:
'/admin/webhooks'
,
label
:
'Webhooks'
,
icon
:
Webhook
},
{
href
:
'/admin/system-health'
,
label
:
'System Health'
,
icon
:
Cpu
},
{
href
:
'/admin/control-panel'
,
label
:
'Control Panel'
,
icon
:
Database
},
];
export
default
function
AdminLayout
({
children
}:
{
children
:
React
.
ReactNode
})
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
router
=
useRouter
();
const
pathname
=
usePathname
();
useEffect
(()
=>
{
if
(
user
&&
user
.
role
!==
'SUPER_ADMIN'
&&
user
.
role
!==
'ADMIN'
)
{
router
.
push
(
'/'
);
}
},
[
user
,
router
]);
if
(
!
user
||
(
user
.
role
!==
'SUPER_ADMIN'
&&
user
.
role
!==
'ADMIN'
))
{
return
null
;
}
const
isSuperAdmin
=
user
.
role
===
'SUPER_ADMIN'
;
const
allLinks
=
isSuperAdmin
?
[...
adminLinks
,
...
superAdminLinks
]
:
adminLinks
;
return
(
<
div
>
{
/* Admin sub-navigation tabs — horizontal scroll */
}
<
div
className=
"mb-6 -mt-2 overflow-x-auto"
>
<
div
className=
"flex gap-1 min-w-max pb-2"
>
{
allLinks
.
map
((
link
)
=>
{
const
Icon
=
link
.
icon
;
const
isActive
=
pathname
===
link
.
href
||
pathname
.
startsWith
(
link
.
href
+
'/'
);
return
(
<
Link
key=
{
link
.
href
}
href=
{
link
.
href
}
className=
{
cn
(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors'
,
isActive
?
'bg-primary text-primary-foreground'
:
'text-muted-foreground hover:bg-accent hover:text-foreground'
,
)
}
>
<
Icon
size=
{
14
}
/>
{
link
.
label
}
</
Link
>
);
})
}
</
div
>
</
div
>
{
children
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/reports/review/page.tsx
0 → 100644
View file @
553c8c4a
This diff is collapsed.
Click to expand it.
frontend/src/components/data-table/data-table-pagination.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
ChevronLeft
,
ChevronRight
}
from
'lucide-react'
;
interface
DataTablePaginationProps
{
page
:
number
;
totalPages
:
number
;
total
:
number
;
limit
:
number
;
onPageChange
:
(
page
:
number
)
=>
void
;
}
export
function
DataTablePagination
({
page
,
totalPages
,
total
,
limit
,
onPageChange
,
}:
DataTablePaginationProps
)
{
if
(
totalPages
<=
1
)
return
null
;
const
start
=
(
page
-
1
)
*
limit
+
1
;
const
end
=
Math
.
min
(
page
*
limit
,
total
);
return
(
<
div
className=
"flex items-center justify-between px-4 py-3 border-t"
>
<
span
className=
"text-xs text-muted-foreground"
>
{
start
}
–
{
end
}
of
{
total
}
</
span
>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
onPageChange
(
page
-
1
)
}
disabled=
{
page
<=
1
}
className=
"flex items-center gap-1 px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
<
ChevronLeft
size=
{
12
}
/>
Previous
</
button
>
<
button
onClick=
{
()
=>
onPageChange
(
page
+
1
)
}
disabled=
{
page
>=
totalPages
}
className=
"flex items-center gap-1 px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next
<
ChevronRight
size=
{
12
}
/>
</
button
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/data-table/data-table.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useState
,
useMemo
}
from
'react'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
ArrowUpDown
,
ChevronLeft
,
ChevronRight
}
from
'lucide-react'
;
export
interface
Column
<
T
>
{
key
:
string
;
header
:
string
;
sortable
?:
boolean
;
className
?:
string
;
render
?:
(
row
:
T
)
=>
React
.
ReactNode
;
}
interface
DataTableProps
<
T
>
{
data
:
T
[];
columns
:
Column
<
T
>
[];
keyExtractor
:
(
row
:
T
)
=>
string
;
onRowClick
?:
(
row
:
T
)
=>
void
;
pageSize
?:
number
;
emptyMessage
?:
string
;
className
?:
string
;
selectable
?:
boolean
;
selectedKeys
?:
Set
<
string
>
;
onSelectionChange
?:
(
keys
:
Set
<
string
>
)
=>
void
;
}
export
function
DataTable
<
T
extends
Record
<
string
,
any
>>
({
data
,
columns
,
keyExtractor
,
onRowClick
,
pageSize
=
20
,
emptyMessage
=
'No data found'
,
className
,
selectable
=
false
,
selectedKeys
,
onSelectionChange
,
}:
DataTableProps
<
T
>
)
{
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
sortKey
,
setSortKey
]
=
useState
<
string
|
null
>
(
null
);
const
[
sortDir
,
setSortDir
]
=
useState
<
'asc'
|
'desc'
>
(
'asc'
);
const
sortedData
=
useMemo
(()
=>
{
if
(
!
sortKey
)
return
data
;
return
[...
data
].
sort
((
a
,
b
)
=>
{
const
aVal
=
a
[
sortKey
];
const
bVal
=
b
[
sortKey
];
if
(
aVal
==
null
)
return
1
;
if
(
bVal
==
null
)
return
-
1
;
if
(
typeof
aVal
===
'string'
)
{
return
sortDir
===
'asc'
?
aVal
.
localeCompare
(
bVal
)
:
bVal
.
localeCompare
(
aVal
);
}
return
sortDir
===
'asc'
?
aVal
-
bVal
:
bVal
-
aVal
;
});
},
[
data
,
sortKey
,
sortDir
]);
const
totalPages
=
Math
.
ceil
(
sortedData
.
length
/
pageSize
);
const
pagedData
=
sortedData
.
slice
(
(
page
-
1
)
*
pageSize
,
page
*
pageSize
,
);
const
handleSort
=
(
key
:
string
)
=>
{
if
(
sortKey
===
key
)
{
setSortDir
((
d
)
=>
(
d
===
'asc'
?
'desc'
:
'asc'
));
}
else
{
setSortKey
(
key
);
setSortDir
(
'asc'
);
}
};
const
toggleAll
=
()
=>
{
if
(
!
onSelectionChange
||
!
selectedKeys
)
return
;
if
(
selectedKeys
.
size
===
pagedData
.
length
)
{
onSelectionChange
(
new
Set
());
}
else
{
onSelectionChange
(
new
Set
(
pagedData
.
map
(
keyExtractor
)));
}
};
const
toggleRow
=
(
key
:
string
)
=>
{
if
(
!
onSelectionChange
||
!
selectedKeys
)
return
;
const
next
=
new
Set
(
selectedKeys
);
if
(
next
.
has
(
key
))
next
.
delete
(
key
);
else
next
.
add
(
key
);
onSelectionChange
(
next
);
};
return
(
<
div
className=
{
cn
(
'bg-card rounded-xl border overflow-hidden'
,
className
)
}
>
<
div
className=
"overflow-x-auto"
>
<
table
className=
"w-full text-sm"
>
<
thead
>
<
tr
className=
"border-b bg-muted/50"
>
{
selectable
&&
(
<
th
className=
"px-3 py-3 w-8"
>
<
input
type=
"checkbox"
checked=
{
selectedKeys
?
selectedKeys
.
size
===
pagedData
.
length
&&
pagedData
.
length
>
0
:
false
}
onChange=
{
toggleAll
}
className=
"rounded"
/>
</
th
>
)
}
{
columns
.
map
((
col
)
=>
(
<
th
key=
{
col
.
key
}
className=
{
cn
(
'text-left px-4 py-3 font-medium text-muted-foreground'
,
col
.
sortable
&&
'cursor-pointer select-none hover:text-foreground'
,
col
.
className
,
)
}
onClick=
{
col
.
sortable
?
()
=>
handleSort
(
col
.
key
)
:
undefined
}
>
<
span
className=
"flex items-center gap-1"
>
{
col
.
header
}
{
col
.
sortable
&&
(
<
ArrowUpDown
size=
{
12
}
className=
{
sortKey
===
col
.
key
?
'text-foreground'
:
'opacity-30'
}
/>
)
}
</
span
>
</
th
>
))
}
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
pagedData
.
map
((
row
)
=>
{
const
key
=
keyExtractor
(
row
);
return
(
<
tr
key=
{
key
}
onClick=
{
()
=>
onRowClick
?.(
row
)
}
className=
{
cn
(
'hover:bg-accent/50 transition-colors'
,
onRowClick
&&
'cursor-pointer'
,
selectedKeys
?.
has
(
key
)
&&
'bg-accent/30'
,
)
}
>
{
selectable
&&
(
<
td
className=
"px-3 py-3"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
input
type=
"checkbox"
checked=
{
selectedKeys
?.
has
(
key
)
||
false
}
onChange=
{
()
=>
toggleRow
(
key
)
}
className=
"rounded"
/>
</
td
>
)
}
{
columns
.
map
((
col
)
=>
(
<
td
key=
{
col
.
key
}
className=
{
cn
(
'px-4 py-3'
,
col
.
className
)
}
>
{
col
.
render
?
col
.
render
(
row
)
:
String
(
row
[
col
.
key
]
??
'—'
)
}
</
td
>
))
}
</
tr
>
);
})
}
{
pagedData
.
length
===
0
&&
(
<
tr
>
<
td
colSpan=
{
columns
.
length
+
(
selectable
?
1
:
0
)
}
className=
"text-center py-12 text-muted-foreground"
>
{
emptyMessage
}
</
td
>
</
tr
>
)
}
</
tbody
>
</
table
>
</
div
>
{
totalPages
>
1
&&
(
<
div
className=
"flex items-center justify-between px-4 py-3 border-t"
>
<
span
className=
"text-xs text-muted-foreground"
>
Showing
{
(
page
-
1
)
*
pageSize
+
1
}
–
{
Math
.
min
(
page
*
pageSize
,
sortedData
.
length
)
}
of
{
' '
}
{
sortedData
.
length
}
</
span
>
<
div
className=
"flex gap-1"
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
Math
.
max
(
1
,
p
-
1
))
}
disabled=
{
page
<=
1
}
className=
"p-1.5 rounded border hover:bg-accent disabled:opacity-50"
>
<
ChevronLeft
size=
{
14
}
/>
</
button
>
{
Array
.
from
({
length
:
Math
.
min
(
totalPages
,
5
)
},
(
_
,
i
)
=>
{
const
pageNum
=
totalPages
<=
5
?
i
+
1
:
page
<=
3
?
i
+
1
:
page
>=
totalPages
-
2
?
totalPages
-
4
+
i
:
page
-
2
+
i
;
return
(
<
button
key=
{
pageNum
}
onClick=
{
()
=>
setPage
(
pageNum
)
}
className=
{
cn
(
'min-w-[28px] h-7 text-xs rounded border'
,
page
===
pageNum
?
'bg-primary text-primary-foreground'
:
'hover:bg-accent'
,
)
}
>
{
pageNum
}
</
button
>
);
})
}
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
Math
.
min
(
totalPages
,
p
+
1
))
}
disabled=
{
page
>=
totalPages
}
className=
"p-1.5 rounded border hover:bg-accent disabled:opacity-50"
>
<
ChevronRight
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/evaluations/competency-radar.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
CompetencyArea
{
name
:
string
;
shortName
?:
string
;
selfScore
:
number
;
plScore
:
number
|
null
;
}
interface
CompetencyRadarProps
{
areas
:
CompetencyArea
[];
className
?:
string
;
size
?:
number
;
}
export
function
CompetencyRadar
({
areas
,
className
,
size
=
300
,
}:
CompetencyRadarProps
)
{
if
(
areas
.
length
===
0
)
{
return
(
<
div
className=
{
cn
(
'text-center py-8 text-muted-foreground text-sm'
,
className
)
}
>
No competency data available
</
div
>
);
}
const
center
=
size
/
2
;
const
maxRadius
=
center
-
40
;
const
levels
=
5
;
const
angleStep
=
(
2
*
Math
.
PI
)
/
areas
.
length
;
const
getPoint
=
(
index
:
number
,
value
:
number
):
{
x
:
number
;
y
:
number
}
=>
{
const
angle
=
angleStep
*
index
-
Math
.
PI
/
2
;
const
radius
=
(
value
/
levels
)
*
maxRadius
;
return
{
x
:
center
+
radius
*
Math
.
cos
(
angle
),
y
:
center
+
radius
*
Math
.
sin
(
angle
),
};
};
const
getPolygonPoints
=
(
scores
:
number
[]):
string
=>
{
return
scores
.
map
((
score
,
i
)
=>
{
const
point
=
getPoint
(
i
,
score
);
return
`
${
point
.
x
}
,
${
point
.
y
}
`
;
})
.
join
(
' '
);
};
const
selfScores
=
areas
.
map
((
a
)
=>
a
.
selfScore
);
const
plScores
=
areas
.
map
((
a
)
=>
a
.
plScore
??
0
);
const
hasPlScores
=
areas
.
some
((
a
)
=>
a
.
plScore
!==
null
);
return
(
<
div
className=
{
cn
(
'flex flex-col items-center'
,
className
)
}
>
<
svg
viewBox=
{
`0 0 ${size} ${size}`
}
width=
{
size
}
height=
{
size
}
className=
"max-w-full"
>
{
/* Background grid */
}
{
Array
.
from
({
length
:
levels
},
(
_
,
i
)
=>
{
const
radius
=
((
i
+
1
)
/
levels
)
*
maxRadius
;
const
points
=
areas
.
map
((
_
,
j
)
=>
{
const
angle
=
angleStep
*
j
-
Math
.
PI
/
2
;
return
`${center + radius * Math.cos(angle)},${center + radius * Math.sin(angle)}`
;
})
.
join
(
' '
);
return
(
<
polygon
key=
{
i
}
points=
{
points
}
fill=
"none"
stroke=
"currentColor"
strokeOpacity=
{
0.1
}
strokeWidth=
{
1
}
/>
);
})
}
{
/* Axis lines */
}
{
areas
.
map
((
_
,
i
)
=>
{
const
point
=
getPoint
(
i
,
levels
);
return
(
<
line
key=
{
i
}
x1=
{
center
}
y1=
{
center
}
x2=
{
point
.
x
}
y2=
{
point
.
y
}
stroke=
"currentColor"
strokeOpacity=
{
0.1
}
strokeWidth=
{
1
}
/>
);
})
}
{
/* PL assessment polygon */
}
{
hasPlScores
&&
(
<
polygon
points=
{
getPolygonPoints
(
plScores
)
}
fill=
"hsl(var(--primary))"
fillOpacity=
{
0.15
}
stroke=
"hsl(var(--primary))"
strokeWidth=
{
2
}
strokeOpacity=
{
0.8
}
/>
)
}
{
/* Self-assessment polygon */
}
<
polygon
points=
{
getPolygonPoints
(
selfScores
)
}
fill=
"hsl(142, 76%, 36%)"
fillOpacity=
{
0.1
}
stroke=
"hsl(142, 76%, 36%)"
strokeWidth=
{
2
}
strokeOpacity=
{
0.6
}
strokeDasharray=
"4 2"
/>
{
/* Data points */
}
{
selfScores
.
map
((
score
,
i
)
=>
{
const
point
=
getPoint
(
i
,
score
);
return
(
<
circle
key=
{
`self-${i}`
}
cx=
{
point
.
x
}
cy=
{
point
.
y
}
r=
{
3
}
fill=
"hsl(142, 76%, 36%)"
stroke=
"white"
strokeWidth=
{
1
}
/>
);
})
}
{
hasPlScores
&&
plScores
.
map
((
score
,
i
)
=>
{
if
(
score
===
0
)
return
null
;
const
point
=
getPoint
(
i
,
score
);
return
(
<
circle
key=
{
`pl-${i}`
}
cx=
{
point
.
x
}
cy=
{
point
.
y
}
r=
{
3
}
fill=
"hsl(var(--primary))"
stroke=
"white"
strokeWidth=
{
1
}
/>
);
})
}
{
/* Labels */
}
{
areas
.
map
((
area
,
i
)
=>
{
const
angle
=
angleStep
*
i
-
Math
.
PI
/
2
;
const
labelRadius
=
maxRadius
+
25
;
const
x
=
center
+
labelRadius
*
Math
.
cos
(
angle
);
const
y
=
center
+
labelRadius
*
Math
.
sin
(
angle
);
let
textAnchor
:
'start'
|
'middle'
|
'end'
=
'middle'
;
if
(
Math
.
cos
(
angle
)
>
0.1
)
textAnchor
=
'start'
;
else
if
(
Math
.
cos
(
angle
)
<
-
0.1
)
textAnchor
=
'end'
;
return
(
<
text
key=
{
i
}
x=
{
x
}
y=
{
y
}
textAnchor=
{
textAnchor
}
dominantBaseline=
"middle"
className=
"fill-muted-foreground"
fontSize=
{
8
}
>
{
(
area
.
shortName
||
area
.
name
).
substring
(
0
,
20
)
}
</
text
>
);
})
}
{
/* Level labels */
}
{
Array
.
from
({
length
:
levels
},
(
_
,
i
)
=>
{
const
val
=
i
+
1
;
const
point
=
getPoint
(
0
,
val
);
return
(
<
text
key=
{
`level-${i}`
}
x=
{
point
.
x
+
4
}
y=
{
point
.
y
-
4
}
className=
"fill-muted-foreground"
fontSize=
{
8
}
opacity=
{
0.5
}
>
{
val
}
</
text
>
);
})
}
</
svg
>
{
/* Legend */
}
<
div
className=
"flex items-center gap-4 mt-3 text-xs"
>
<
span
className=
"flex items-center gap-1.5"
>
<
span
className=
"w-3 h-0.5 inline-block"
style=
{
{
backgroundColor
:
'hsl(142, 76%, 36%)'
,
borderTop
:
'2px dashed hsl(142, 76%, 36%)'
,
}
}
/>
Self-Assessment
</
span
>
{
hasPlScores
&&
(
<
span
className=
"flex items-center gap-1.5"
>
<
span
className=
"w-3 h-0.5 inline-block bg-primary"
style=
{
{
borderTop
:
'2px solid hsl(var(--primary))'
}
}
/>
PL Assessment
</
span
>
)
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/forms/rich-text-editor.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useState
,
useCallback
}
from
'react'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Bold
,
Italic
,
List
,
ListOrdered
,
Code
,
Link2
,
Heading2
,
Quote
,
Minus
,
Undo
,
Redo
,
}
from
'lucide-react'
;
interface
RichTextEditorProps
{
value
:
string
;
onChange
:
(
value
:
string
)
=>
void
;
placeholder
?:
string
;
minHeight
?:
string
;
className
?:
string
;
disabled
?:
boolean
;
simple
?:
boolean
;
}
/**
* Lightweight rich text editor using contentEditable.
* For production, replace with Tiptap once @tiptap/react is installed.
* This provides a working editor with basic formatting.
*/
export
function
RichTextEditor
({
value
,
onChange
,
placeholder
=
'Write something...'
,
minHeight
=
'120px'
,
className
,
disabled
=
false
,
simple
=
false
,
}:
RichTextEditorProps
)
{
const
[
isFocused
,
setIsFocused
]
=
useState
(
false
);
const
execCommand
=
useCallback
((
command
:
string
,
value
?:
string
)
=>
{
document
.
execCommand
(
command
,
false
,
value
);
},
[]);
const
handleInput
=
useCallback
(
(
e
:
React
.
FormEvent
<
HTMLDivElement
>
)
=>
{
const
html
=
(
e
.
target
as
HTMLDivElement
).
innerHTML
;
onChange
(
html
===
'<br>'
?
''
:
html
);
},
[
onChange
],
);
const
handleKeyDown
=
useCallback
(
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'Tab'
)
{
e
.
preventDefault
();
execCommand
(
'insertText'
,
' '
);
}
if
(
e
.
key
===
'b'
&&
(
e
.
metaKey
||
e
.
ctrlKey
))
{
e
.
preventDefault
();
execCommand
(
'bold'
);
}
if
(
e
.
key
===
'i'
&&
(
e
.
metaKey
||
e
.
ctrlKey
))
{
e
.
preventDefault
();
execCommand
(
'italic'
);
}
},
[
execCommand
],
);
const
toolbarButtons
=
simple
?
[
{
icon
:
Bold
,
command
:
'bold'
,
title
:
'Bold (Ctrl+B)'
},
{
icon
:
Italic
,
command
:
'italic'
,
title
:
'Italic (Ctrl+I)'
},
{
icon
:
Code
,
command
:
'insertHTML'
,
value
:
'<code>'
,
title
:
'Code'
},
]
:
[
{
icon
:
Bold
,
command
:
'bold'
,
title
:
'Bold (Ctrl+B)'
},
{
icon
:
Italic
,
command
:
'italic'
,
title
:
'Italic (Ctrl+I)'
},
{
icon
:
Heading2
,
command
:
'formatBlock'
,
value
:
'H3'
,
title
:
'Heading'
},
{
icon
:
List
,
command
:
'insertUnorderedList'
,
title
:
'Bullet List'
},
{
icon
:
ListOrdered
,
command
:
'insertOrderedList'
,
title
:
'Numbered List'
},
{
icon
:
Code
,
command
:
'formatBlock'
,
value
:
'PRE'
,
title
:
'Code Block'
},
{
icon
:
Quote
,
command
:
'formatBlock'
,
value
:
'BLOCKQUOTE'
,
title
:
'Quote'
},
{
icon
:
Minus
,
command
:
'insertHorizontalRule'
,
title
:
'Horizontal Rule'
},
{
icon
:
Undo
,
command
:
'undo'
,
title
:
'Undo'
},
{
icon
:
Redo
,
command
:
'redo'
,
title
:
'Redo'
},
];
return
(
<
div
className=
{
cn
(
'rounded-lg border transition-colors'
,
isFocused
&&
'ring-2 ring-ring'
,
disabled
&&
'opacity-50 pointer-events-none'
,
className
,
)
}
>
{
/* Toolbar */
}
<
div
className=
"flex items-center gap-0.5 p-1.5 border-b bg-muted/30 flex-wrap"
>
{
toolbarButtons
.
map
((
btn
,
i
)
=>
{
const
Icon
=
btn
.
icon
;
return
(
<
button
key=
{
i
}
type=
"button"
onMouseDown=
{
(
e
)
=>
{
e
.
preventDefault
();
if
(
btn
.
value
)
{
if
(
btn
.
command
===
'formatBlock'
)
{
execCommand
(
btn
.
command
,
btn
.
value
);
}
else
if
(
btn
.
command
===
'insertHTML'
)
{
execCommand
(
btn
.
command
,
`<code>${window.getSelection()?.toString() || ''}</code>`
);
}
}
else
{
execCommand
(
btn
.
command
);
}
}
}
className=
"p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title=
{
btn
.
title
}
>
<
Icon
size=
{
14
}
/>
</
button
>
);
})
}
</
div
>
{
/* Editor */
}
<
div
contentEditable=
{
!
disabled
}
suppressContentEditableWarning
onInput=
{
handleInput
}
onFocus=
{
()
=>
setIsFocused
(
true
)
}
onBlur=
{
()
=>
setIsFocused
(
false
)
}
onKeyDown=
{
handleKeyDown
}
dangerouslySetInnerHTML=
{
{
__html
:
value
||
''
}
}
className=
{
cn
(
'px-3 py-2 text-sm outline-none prose prose-sm dark:prose-invert max-w-none'
,
!
value
&&
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none'
,
)
}
data
-
placeholder=
{
placeholder
}
style=
{
{
minHeight
}
}
/>
</
div
>
);
}
/**
* Read-only rich text renderer
*/
export
function
RichTextDisplay
({
content
,
className
,
}:
{
content
:
string
;
className
?:
string
;
})
{
if
(
!
content
)
return
null
;
return
(
<
div
className=
{
cn
(
'prose prose-sm dark:prose-invert max-w-none'
,
'prose-headings:font-semibold prose-headings:text-foreground'
,
'prose-p:text-muted-foreground prose-p:leading-relaxed'
,
'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs'
,
'prose-pre:bg-muted prose-pre:rounded-lg'
,
'prose-blockquote:border-primary/30'
,
className
,
)
}
dangerouslySetInnerHTML=
{
{
__html
:
content
}
}
/>
);
}
\ No newline at end of file
frontend/src/components/layout/theme-toggle.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useTheme
}
from
'next-themes'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
Sun
,
Moon
,
Monitor
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
export
function
ThemeToggle
({
className
}:
{
className
?:
string
})
{
const
{
theme
,
setTheme
,
resolvedTheme
}
=
useTheme
();
const
[
mounted
,
setMounted
]
=
useState
(
false
);
useEffect
(()
=>
setMounted
(
true
),
[]);
if
(
!
mounted
)
{
return
<
div
className=
{
cn
(
'w-8 h-8'
,
className
)
}
/>;
}
const
options
=
[
{
value
:
'light'
,
icon
:
Sun
,
label
:
'Light'
},
{
value
:
'dark'
,
icon
:
Moon
,
label
:
'Dark'
},
{
value
:
'system'
,
icon
:
Monitor
,
label
:
'System'
},
]
as
const
;
return
(
<
div
className=
{
cn
(
'flex items-center gap-0.5 rounded-lg border p-0.5'
,
className
)
}
>
{
options
.
map
((
opt
)
=>
{
const
Icon
=
opt
.
icon
;
const
isActive
=
theme
===
opt
.
value
;
return
(
<
button
key=
{
opt
.
value
}
onClick=
{
()
=>
setTheme
(
opt
.
value
)
}
className=
{
cn
(
'p-1.5 rounded-md transition-colors'
,
isActive
?
'bg-accent text-accent-foreground'
:
'text-muted-foreground hover:text-foreground'
,
)
}
title=
{
opt
.
label
}
>
<
Icon
size=
{
14
}
/>
</
button
>
);
})
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/notifications/notification-bell.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useState
,
useRef
,
useEffect
}
from
'react'
;
import
{
Bell
}
from
'lucide-react'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
NotificationDropdown
}
from
'./notification-dropdown'
;
import
{
cn
}
from
'@/lib/utils'
;
export
function
NotificationBell
()
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
{
unreadCount
}
=
useNotificationStore
();
const
ref
=
useRef
<
HTMLDivElement
>
(
null
);
useEffect
(()
=>
{
const
handleClickOutside
=
(
e
:
MouseEvent
)
=>
{
if
(
ref
.
current
&&
!
ref
.
current
.
contains
(
e
.
target
as
Node
))
{
setIsOpen
(
false
);
}
};
if
(
isOpen
)
{
document
.
addEventListener
(
'mousedown'
,
handleClickOutside
);
}
return
()
=>
document
.
removeEventListener
(
'mousedown'
,
handleClickOutside
);
},
[
isOpen
]);
return
(
<
div
className=
"relative"
ref=
{
ref
}
>
<
button
onClick=
{
()
=>
setIsOpen
(
!
isOpen
)
}
className=
{
cn
(
'relative p-2 rounded-lg transition-colors'
,
isOpen
?
'bg-accent'
:
'hover:bg-accent/50'
,
)
}
title=
"Notifications"
>
<
Bell
size=
{
18
}
/>
{
unreadCount
>
0
&&
(
<
span
className=
"absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-destructive text-destructive-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1 animate-fade-in"
>
{
unreadCount
>
99
?
'99+'
:
unreadCount
}
</
span
>
)
}
</
button
>
{
isOpen
&&
(
<
NotificationDropdown
onClose=
{
()
=>
setIsOpen
(
false
)
}
/>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/notifications/notification-dropdown.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPut
}
from
'@/lib/api'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
relativeTime
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Bell
,
CheckCheck
,
AlertTriangle
,
AlertCircle
,
Info
,
Loader2
,
ExternalLink
,
}
from
'lucide-react'
;
interface
NotificationDropdownProps
{
onClose
:
()
=>
void
;
}
export
function
NotificationDropdown
({
onClose
}:
NotificationDropdownProps
)
{
const
router
=
useRouter
();
const
{
fetchUnreadCount
}
=
useNotificationStore
();
const
[
notifications
,
setNotifications
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
loadNotifications
();
},
[]);
const
loadNotifications
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/notifications'
,
{
limit
:
20
,
sortOrder
:
'desc'
});
setNotifications
(
res
.
data
||
[]);
}
catch
{
// fail silently
}
finally
{
setIsLoading
(
false
);
}
};
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
{
// fail silently
}
};
const
handleMarkAllRead
=
async
()
=>
{
try
{
await
apiPut
(
'/notifications/read-all'
);
setNotifications
((
prev
)
=>
prev
.
map
((
n
)
=>
({
...
n
,
isRead
:
true
})));
fetchUnreadCount
();
}
catch
{
// fail silently
}
};
const
handleClick
=
(
notif
:
any
)
=>
{
if
(
!
notif
.
isRead
)
{
handleMarkRead
(
notif
.
id
);
}
if
(
notif
.
link
)
{
router
.
push
(
notif
.
link
);
onClose
();
}
};
const
getIcon
=
(
type
:
string
)
=>
{
switch
(
type
)
{
case
'BLOCKING'
:
return
<
AlertTriangle
size=
{
14
}
className=
"text-red-500 shrink-0"
/>;
case
'IMPORTANT'
:
return
<
AlertCircle
size=
{
14
}
className=
"text-yellow-500 shrink-0"
/>;
default
:
return
<
Info
size=
{
14
}
className=
"text-blue-500 shrink-0"
/>;
}
};
const
unreadCount
=
notifications
.
filter
((
n
)
=>
!
n
.
isRead
).
length
;
return
(
<
div
className=
"absolute right-0 top-full mt-2 w-96 max-h-[70vh] bg-card rounded-xl border shadow-xl z-50 animate-fade-in overflow-hidden"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between px-4 py-3 border-b"
>
<
h3
className=
"text-sm font-semibold"
>
Notifications
</
h3
>
<
div
className=
"flex items-center gap-2"
>
{
unreadCount
>
0
&&
(
<
button
onClick=
{
handleMarkAllRead
}
className=
"flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<
CheckCheck
size=
{
12
}
/>
Mark all read
</
button
>
)
}
</
div
>
</
div
>
{
/* Content */
}
<
div
className=
"overflow-y-auto max-h-[calc(70vh-100px)]"
>
{
isLoading
?
(
<
div
className=
"flex items-center justify-center py-8"
>
<
Loader2
size=
{
20
}
className=
"animate-spin text-muted-foreground"
/>
</
div
>
)
:
notifications
.
length
===
0
?
(
<
div
className=
"text-center py-8 text-muted-foreground"
>
<
Bell
size=
{
24
}
className=
"mx-auto mb-2 opacity-30"
/>
<
p
className=
"text-sm"
>
No notifications
</
p
>
</
div
>
)
:
(
<
div
className=
"divide-y"
>
{
notifications
.
map
((
notif
)
=>
(
<
button
key=
{
notif
.
id
}
onClick=
{
()
=>
handleClick
(
notif
)
}
className=
{
cn
(
'w-full text-left px-4 py-3 flex gap-3 transition-colors hover:bg-accent/50'
,
!
notif
.
isRead
&&
'bg-accent/20'
,
)
}
>
<
div
className=
"mt-0.5"
>
{
getIcon
(
notif
.
type
)
}
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
{
cn
(
'text-xs line-clamp-2'
,
!
notif
.
isRead
&&
'font-medium'
,
)
}
>
{
notif
.
title
||
notif
.
message
}
</
p
>
{
notif
.
message
&&
notif
.
title
&&
(
<
p
className=
"text-[10px] text-muted-foreground mt-0.5 line-clamp-1"
>
{
notif
.
message
}
</
p
>
)
}
<
p
className=
"text-[10px] text-muted-foreground mt-1"
>
{
relativeTime
(
notif
.
createdAt
)
}
</
p
>
</
div
>
{
!
notif
.
isRead
&&
(
<
div
className=
"w-2 h-2 rounded-full bg-primary shrink-0 mt-1.5"
/>
)
}
</
button
>
))
}
</
div
>
)
}
</
div
>
{
/* Footer */
}
<
div
className=
"border-t px-4 py-2"
>
<
button
onClick=
{
()
=>
{
router
.
push
(
'/notifications'
);
onClose
();
}
}
className=
"flex items-center gap-1 text-xs text-primary hover:underline w-full justify-center"
>
View all notifications
<
ExternalLink
size=
{
10
}
/>
</
button
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/shared/relative-time.tsx
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
relativeTime
as
formatRelativeTime
}
from
'@/lib/date'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
RelativeTimeProps
{
date
:
string
|
Date
;
className
?:
string
;
refreshInterval
?:
number
;
// ms, default 60000 (1 min)
}
export
function
RelativeTime
({
date
,
className
,
refreshInterval
=
60000
,
}:
RelativeTimeProps
)
{
const
[
display
,
setDisplay
]
=
useState
(()
=>
formatRelativeTime
(
date
));
useEffect
(()
=>
{
setDisplay
(
formatRelativeTime
(
date
));
const
interval
=
setInterval
(()
=>
{
setDisplay
(
formatRelativeTime
(
date
));
},
refreshInterval
);
return
()
=>
clearInterval
(
interval
);
},
[
date
,
refreshInterval
]);
const
isoString
=
typeof
date
===
'string'
?
date
:
date
.
toISOString
();
return
(
<
time
dateTime=
{
isoString
}
title=
{
new
Date
(
isoString
).
toLocaleString
()
}
className=
{
cn
(
'text-muted-foreground'
,
className
)
}
>
{
display
}
</
time
>
);
}
\ No newline at end of file
frontend/src/hooks/use-board.ts
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useEffect
,
useCallback
}
from
'react'
;
import
{
useSocket
}
from
'@/hooks/use-socket'
;
import
{
useBoardStore
}
from
'@/stores/board.store'
;
export
function
useBoard
(
boardId
:
string
|
null
)
{
const
socket
=
useSocket
();
const
{
setActiveBoard
,
addCard
,
updateCard
,
removeCard
,
moveCard
,
setConnected
,
}
=
useBoardStore
();
useEffect
(()
=>
{
if
(
!
boardId
||
!
socket
)
return
;
setActiveBoard
(
boardId
);
socket
.
emit
(
'board:join'
,
{
boardId
});
setConnected
(
true
);
const
handleCardCreated
=
(
data
:
any
)
=>
{
if
(
data
.
boardId
===
boardId
)
{
addCard
(
data
.
card
||
data
);
}
};
const
handleCardUpdated
=
(
data
:
any
)
=>
{
if
(
data
.
boardId
===
boardId
)
{
updateCard
(
data
.
cardId
||
data
.
id
,
data
.
updates
||
data
);
}
};
const
handleCardMoved
=
(
data
:
any
)
=>
{
if
(
data
.
boardId
===
boardId
)
{
moveCard
(
data
.
cardId
,
data
.
columnId
,
data
.
position
);
}
};
const
handleCardDeleted
=
(
data
:
any
)
=>
{
if
(
data
.
boardId
===
boardId
)
{
removeCard
(
data
.
cardId
||
data
.
id
);
}
};
const
handleCardAssigned
=
(
data
:
any
)
=>
{
if
(
data
.
boardId
===
boardId
&&
data
.
card
)
{
updateCard
(
data
.
cardId
||
data
.
card
.
id
,
{
assignees
:
data
.
card
.
assignees
||
data
.
assignees
,
});
}
};
socket
.
on
(
'card:created'
,
handleCardCreated
);
socket
.
on
(
'card:updated'
,
handleCardUpdated
);
socket
.
on
(
'card:moved'
,
handleCardMoved
);
socket
.
on
(
'card:deleted'
,
handleCardDeleted
);
socket
.
on
(
'card:archived'
,
handleCardDeleted
);
socket
.
on
(
'card:assigned'
,
handleCardAssigned
);
return
()
=>
{
socket
.
emit
(
'board:leave'
,
{
boardId
});
socket
.
off
(
'card:created'
,
handleCardCreated
);
socket
.
off
(
'card:updated'
,
handleCardUpdated
);
socket
.
off
(
'card:moved'
,
handleCardMoved
);
socket
.
off
(
'card:deleted'
,
handleCardDeleted
);
socket
.
off
(
'card:archived'
,
handleCardDeleted
);
socket
.
off
(
'card:assigned'
,
handleCardAssigned
);
setActiveBoard
(
null
);
setConnected
(
false
);
};
},
[
boardId
,
socket
]);
const
emitCardMove
=
useCallback
(
(
cardId
:
string
,
columnId
:
string
,
position
:
number
)
=>
{
if
(
!
socket
||
!
boardId
)
return
;
socket
.
emit
(
'card:move'
,
{
boardId
,
cardId
,
columnId
,
position
});
},
[
socket
,
boardId
],
);
return
{
emitCardMove
};
}
\ No newline at end of file
frontend/src/hooks/use-messages.ts
0 → 100644
View file @
553c8c4a
'use client'
;
import
{
useEffect
,
useCallback
,
useRef
}
from
'react'
;
import
{
useSocket
}
from
'@/hooks/use-socket'
;
import
{
useMessageStore
}
from
'@/stores/message.store'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
apiGet
}
from
'@/lib/api'
;
export
function
useMessages
()
{
const
socket
=
useSocket
();
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
{
setConversations
,
addMessage
,
updateConversation
,
setTypingUser
,
clearTypingUser
,
markConversationRead
,
activeConversationId
,
setLoaded
,
isLoaded
,
}
=
useMessageStore
();
const
typingTimeoutsRef
=
useRef
<
Record
<
string
,
NodeJS
.
Timeout
>>
({});
// Load conversations on mount
useEffect
(()
=>
{
if
(
!
user
||
isLoaded
)
return
;
apiGet
(
'/conversations'
)
.
then
((
res
)
=>
setConversations
(
res
.
data
||
[]))
.
catch
(()
=>
setLoaded
(
true
));
},
[
user
,
isLoaded
]);
// Socket listeners
useEffect
(()
=>
{
if
(
!
socket
||
!
user
)
return
;
const
handleNewMessage
=
(
data
:
any
)
=>
{
const
message
=
data
.
message
||
data
;
const
conversationId
=
message
.
conversationId
;
if
(
!
conversationId
)
return
;
addMessage
(
conversationId
,
message
);
};
const
handleTyping
=
(
data
:
any
)
=>
{
const
{
conversationId
,
userId
}
=
data
;
if
(
userId
===
user
.
id
)
return
;
setTypingUser
(
conversationId
,
userId
);
const
key
=
`
${
conversationId
}
:
${
userId
}
`
;
if
(
typingTimeoutsRef
.
current
[
key
])
{
clearTimeout
(
typingTimeoutsRef
.
current
[
key
]);
}
typingTimeoutsRef
.
current
[
key
]
=
setTimeout
(()
=>
{
clearTypingUser
(
conversationId
,
userId
);
delete
typingTimeoutsRef
.
current
[
key
];
},
3000
);
};
const
handleMessageRead
=
(
data
:
any
)
=>
{
const
{
conversationId
}
=
data
;
if
(
conversationId
)
{
updateConversation
(
conversationId
,
{
unreadCount
:
0
});
}
};
socket
.
on
(
'message:new'
,
handleNewMessage
);
socket
.
on
(
'message:typing'
,
handleTyping
);
socket
.
on
(
'message:read'
,
handleMessageRead
);
return
()
=>
{
socket
.
off
(
'message:new'
,
handleNewMessage
);
socket
.
off
(
'message:typing'
,
handleTyping
);
socket
.
off
(
'message:read'
,
handleMessageRead
);
Object
.
values
(
typingTimeoutsRef
.
current
).
forEach
(
clearTimeout
);
typingTimeoutsRef
.
current
=
{};
};
},
[
socket
,
user
]);
const
sendTypingIndicator
=
useCallback
(
(
conversationId
:
string
)
=>
{
if
(
!
socket
)
return
;
socket
.
emit
(
'message:typing'
,
{
conversationId
});
},
[
socket
],
);
const
markRead
=
useCallback
(
(
conversationId
:
string
)
=>
{
if
(
!
socket
)
return
;
markConversationRead
(
conversationId
);
socket
.
emit
(
'message:read'
,
{
conversationId
});
},
[
socket
,
markConversationRead
],
);
return
{
sendTypingIndicator
,
markRead
};
}
\ No newline at end of file
frontend/src/stores/board.store.ts
0 → 100644
View file @
553c8c4a
import
{
create
}
from
'zustand'
;
interface
CardData
{
id
:
string
;
title
:
string
;
cardNumber
:
string
;
columnId
:
string
;
position
:
number
;
priority
:
string
;
dueDate
:
string
|
null
;
bountyPiasters
:
number
;
isArchived
:
boolean
;
assignees
:
any
[];
labels
:
any
[];
commentCount
:
number
;
attachmentCount
:
number
;
checklistProgress
:
{
completed
:
number
;
total
:
number
}
|
null
;
completedAt
:
string
|
null
;
version
:
number
;
}
interface
BoardState
{
activeBoardId
:
string
|
null
;
cards
:
Record
<
string
,
CardData
>
;
columns
:
any
[];
isConnected
:
boolean
;
dragState
:
{
cardId
:
string
;
sourceColumnId
:
string
}
|
null
;
setActiveBoard
:
(
boardId
:
string
|
null
)
=>
void
;
setColumns
:
(
columns
:
any
[])
=>
void
;
setCards
:
(
cards
:
CardData
[])
=>
void
;
addCard
:
(
card
:
CardData
)
=>
void
;
updateCard
:
(
cardId
:
string
,
updates
:
Partial
<
CardData
>
)
=>
void
;
removeCard
:
(
cardId
:
string
)
=>
void
;
moveCard
:
(
cardId
:
string
,
columnId
:
string
,
position
:
number
)
=>
void
;
setDragState
:
(
state
:
{
cardId
:
string
;
sourceColumnId
:
string
}
|
null
)
=>
void
;
setConnected
:
(
connected
:
boolean
)
=>
void
;
getCardsByColumn
:
(
columnId
:
string
)
=>
CardData
[];
getCard
:
(
cardId
:
string
)
=>
CardData
|
undefined
;
}
export
const
useBoardStore
=
create
<
BoardState
>
((
set
,
get
)
=>
({
activeBoardId
:
null
,
cards
:
{},
columns
:
[],
isConnected
:
false
,
dragState
:
null
,
setActiveBoard
:
(
boardId
)
=>
set
({
activeBoardId
:
boardId
}),
setColumns
:
(
columns
)
=>
set
({
columns
}),
setCards
:
(
cards
)
=>
{
const
cardMap
:
Record
<
string
,
CardData
>
=
{};
for
(
const
card
of
cards
)
{
cardMap
[
card
.
id
]
=
card
;
}
set
({
cards
:
cardMap
});
},
addCard
:
(
card
)
=>
set
((
state
)
=>
({
cards
:
{
...
state
.
cards
,
[
card
.
id
]:
card
},
})),
updateCard
:
(
cardId
,
updates
)
=>
set
((
state
)
=>
{
const
existing
=
state
.
cards
[
cardId
];
if
(
!
existing
)
return
state
;
return
{
cards
:
{
...
state
.
cards
,
[
cardId
]:
{
...
existing
,
...
updates
},
},
};
}),
removeCard
:
(
cardId
)
=>
set
((
state
)
=>
{
const
next
=
{
...
state
.
cards
};
delete
next
[
cardId
];
return
{
cards
:
next
};
}),
moveCard
:
(
cardId
,
columnId
,
position
)
=>
set
((
state
)
=>
{
const
existing
=
state
.
cards
[
cardId
];
if
(
!
existing
)
return
state
;
return
{
cards
:
{
...
state
.
cards
,
[
cardId
]:
{
...
existing
,
columnId
,
position
},
},
};
}),
setDragState
:
(
dragState
)
=>
set
({
dragState
}),
setConnected
:
(
connected
)
=>
set
({
isConnected
:
connected
}),
getCardsByColumn
:
(
columnId
)
=>
{
const
{
cards
}
=
get
();
return
Object
.
values
(
cards
)
.
filter
((
c
)
=>
c
.
columnId
===
columnId
&&
!
c
.
isArchived
)
.
sort
((
a
,
b
)
=>
(
a
.
position
||
0
)
-
(
b
.
position
||
0
));
},
getCard
:
(
cardId
)
=>
get
().
cards
[
cardId
],
}));
\ No newline at end of file
frontend/src/stores/message.store.ts
0 → 100644
View file @
553c8c4a
import
{
create
}
from
'zustand'
;
interface
Conversation
{
id
:
string
;
name
:
string
|
null
;
type
:
'DIRECT'
|
'GROUP'
;
participants
:
any
[];
lastMessageAt
:
string
|
null
;
lastMessagePreview
:
string
|
null
;
unreadCount
:
number
;
}
interface
Message
{
id
:
string
;
conversationId
:
string
;
senderId
:
string
;
sender
?:
any
;
content
:
string
;
attachments
:
any
[];
createdAt
:
string
;
isRead
:
boolean
;
}
interface
MessageState
{
conversations
:
Conversation
[];
activeConversationId
:
string
|
null
;
messages
:
Record
<
string
,
Message
[]
>
;
typingUsers
:
Record
<
string
,
string
[]
>
;
totalUnread
:
number
;
isLoaded
:
boolean
;
setConversations
:
(
conversations
:
Conversation
[])
=>
void
;
setActiveConversation
:
(
id
:
string
|
null
)
=>
void
;
addConversation
:
(
conversation
:
Conversation
)
=>
void
;
updateConversation
:
(
id
:
string
,
updates
:
Partial
<
Conversation
>
)
=>
void
;
setMessages
:
(
conversationId
:
string
,
messages
:
Message
[])
=>
void
;
addMessage
:
(
conversationId
:
string
,
message
:
Message
)
=>
void
;
markConversationRead
:
(
conversationId
:
string
)
=>
void
;
setTypingUser
:
(
conversationId
:
string
,
userId
:
string
)
=>
void
;
clearTypingUser
:
(
conversationId
:
string
,
userId
:
string
)
=>
void
;
setLoaded
:
(
loaded
:
boolean
)
=>
void
;
computeTotalUnread
:
()
=>
void
;
}
export
const
useMessageStore
=
create
<
MessageState
>
((
set
,
get
)
=>
({
conversations
:
[],
activeConversationId
:
null
,
messages
:
{},
typingUsers
:
{},
totalUnread
:
0
,
isLoaded
:
false
,
setConversations
:
(
conversations
)
=>
{
const
totalUnread
=
conversations
.
reduce
((
sum
,
c
)
=>
sum
+
(
c
.
unreadCount
||
0
),
0
);
set
({
conversations
,
totalUnread
,
isLoaded
:
true
});
},
setActiveConversation
:
(
id
)
=>
set
({
activeConversationId
:
id
}),
addConversation
:
(
conversation
)
=>
set
((
state
)
=>
{
const
exists
=
state
.
conversations
.
find
((
c
)
=>
c
.
id
===
conversation
.
id
);
if
(
exists
)
{
return
{
conversations
:
state
.
conversations
.
map
((
c
)
=>
c
.
id
===
conversation
.
id
?
{
...
c
,
...
conversation
}
:
c
,
),
};
}
return
{
conversations
:
[
conversation
,
...
state
.
conversations
]
};
}),
updateConversation
:
(
id
,
updates
)
=>
set
((
state
)
=>
({
conversations
:
state
.
conversations
.
map
((
c
)
=>
c
.
id
===
id
?
{
...
c
,
...
updates
}
:
c
,
),
})),
setMessages
:
(
conversationId
,
messages
)
=>
set
((
state
)
=>
({
messages
:
{
...
state
.
messages
,
[
conversationId
]:
messages
},
})),
addMessage
:
(
conversationId
,
message
)
=>
set
((
state
)
=>
{
const
existing
=
state
.
messages
[
conversationId
]
||
[];
const
alreadyExists
=
existing
.
some
((
m
)
=>
m
.
id
===
message
.
id
);
if
(
alreadyExists
)
return
state
;
const
updatedMessages
=
{
...
state
.
messages
,
[
conversationId
]:
[...
existing
,
message
],
};
const
updatedConversations
=
state
.
conversations
.
map
((
c
)
=>
{
if
(
c
.
id
===
conversationId
)
{
return
{
...
c
,
lastMessageAt
:
message
.
createdAt
,
lastMessagePreview
:
message
.
content
?.
substring
(
0
,
60
)
||
''
,
unreadCount
:
state
.
activeConversationId
===
conversationId
?
c
.
unreadCount
:
c
.
unreadCount
+
1
,
};
}
return
c
;
});
const
sortedConversations
=
updatedConversations
.
sort
((
a
,
b
)
=>
{
const
aTime
=
a
.
lastMessageAt
?
new
Date
(
a
.
lastMessageAt
).
getTime
()
:
0
;
const
bTime
=
b
.
lastMessageAt
?
new
Date
(
b
.
lastMessageAt
).
getTime
()
:
0
;
return
bTime
-
aTime
;
});
return
{
messages
:
updatedMessages
,
conversations
:
sortedConversations
,
};
}),
markConversationRead
:
(
conversationId
)
=>
set
((
state
)
=>
{
const
conv
=
state
.
conversations
.
find
((
c
)
=>
c
.
id
===
conversationId
);
const
unreadDelta
=
conv
?.
unreadCount
||
0
;
return
{
conversations
:
state
.
conversations
.
map
((
c
)
=>
c
.
id
===
conversationId
?
{
...
c
,
unreadCount
:
0
}
:
c
,
),
totalUnread
:
Math
.
max
(
0
,
state
.
totalUnread
-
unreadDelta
),
};
}),
setTypingUser
:
(
conversationId
,
userId
)
=>
set
((
state
)
=>
{
const
current
=
state
.
typingUsers
[
conversationId
]
||
[];
if
(
current
.
includes
(
userId
))
return
state
;
return
{
typingUsers
:
{
...
state
.
typingUsers
,
[
conversationId
]:
[...
current
,
userId
],
},
};
}),
clearTypingUser
:
(
conversationId
,
userId
)
=>
set
((
state
)
=>
({
typingUsers
:
{
...
state
.
typingUsers
,
[
conversationId
]:
(
state
.
typingUsers
[
conversationId
]
||
[]).
filter
(
(
id
)
=>
id
!==
userId
,
),
},
})),
setLoaded
:
(
loaded
)
=>
set
({
isLoaded
:
loaded
}),
computeTotalUnread
:
()
=>
set
((
state
)
=>
({
totalUnread
:
state
.
conversations
.
reduce
(
(
sum
,
c
)
=>
sum
+
(
c
.
unreadCount
||
0
),
0
,
),
})),
}));
\ 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