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
731cd6e6
Commit
731cd6e6
authored
Apr 02, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 10 files via Son of Anton
parent
78d69125
Changes
10
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
1847 additions
and
58 deletions
+1847
-58
page.tsx
frontend/src/app/(auth)/register/[inviteCode]/page.tsx
+530
-0
page.tsx
...app/(dashboard)/admin/contractors/[contractorId]/page.tsx
+274
-0
page.tsx
frontend/src/app/(dashboard)/admin/control-panel/page.tsx
+211
-0
page.tsx
...tend/src/app/(dashboard)/admin/deductions/create/page.tsx
+152
-0
page.tsx
frontend/src/app/(dashboard)/admin/invites/page.tsx
+144
-0
page.tsx
...nd/src/app/(dashboard)/boards/[boardId]/calendar/page.tsx
+167
-0
topbar.tsx
frontend/src/components/layout/topbar.tsx
+84
-58
command-palette.tsx
frontend/src/components/search/command-palette.tsx
+209
-0
use-keyboard-shortcut.ts
frontend/src/hooks/use-keyboard-shortcut.ts
+61
-0
search.store.ts
frontend/src/stores/search.store.ts
+15
-0
No files found.
frontend/src/app/(auth)/register/[inviteCode]/page.tsx
0 → 100644
View file @
731cd6e6
This diff is collapsed.
Click to expand it.
frontend/src/app/(dashboard)/admin/contractors/[contractorId]/page.tsx
0 → 100644
View file @
731cd6e6
This diff is collapsed.
Click to expand it.
frontend/src/app/(dashboard)/admin/control-panel/page.tsx
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiDelete
}
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
{
ConfirmDialog
}
from
'@/components/shared/confirm-dialog'
;
import
{
formatDate
}
from
'@/lib/date'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
import
{
Users
,
Kanban
,
FileText
,
AlertTriangle
,
DollarSign
,
Star
,
Calendar
,
Bell
,
BookOpen
,
Shield
,
Webhook
,
Key
,
Search
,
Trash2
,
ChevronLeft
,
ChevronRight
,
Database
,
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
const
ENTITIES
=
[
{
key
:
'users'
,
label
:
'Users'
,
icon
:
Users
,
endpoint
:
'/users'
,
columns
:
[
'firstName'
,
'lastName'
,
'username'
,
'role'
,
'status'
]
},
{
key
:
'boards'
,
label
:
'Boards'
,
icon
:
Kanban
,
endpoint
:
'/boards'
,
columns
:
[
'name'
,
'key'
,
'memberCount'
,
'isArchived'
]
},
{
key
:
'cards'
,
label
:
'Cards'
,
icon
:
FileText
,
endpoint
:
'/cards'
,
params
:
{
limit
:
20
},
columns
:
[
'cardNumber'
,
'title'
,
'priority'
,
'isArchived'
]
},
{
key
:
'deductions'
,
label
:
'Deductions'
,
icon
:
AlertTriangle
,
endpoint
:
'/deductions'
,
columns
:
[
'category'
,
'subCategory'
,
'status'
,
'amountPiasters'
]
},
{
key
:
'adjustments'
,
label
:
'Adjustments'
,
icon
:
DollarSign
,
endpoint
:
'/adjustments'
,
columns
:
[
'type'
,
'category'
,
'amountPiasters'
,
'status'
]
},
{
key
:
'evaluations'
,
label
:
'Evaluations'
,
icon
:
Star
,
endpoint
:
'/evaluations'
,
columns
:
[
'month'
,
'year'
,
'overallScore'
,
'status'
]
},
{
key
:
'pips'
,
label
:
'PIPs'
,
icon
:
AlertTriangle
,
endpoint
:
'/pips'
,
columns
:
[
'status'
,
'startDate'
,
'endDate'
]
},
{
key
:
'holidays'
,
label
:
'Holidays'
,
icon
:
Calendar
,
endpoint
:
'/holidays'
,
columns
:
[
'name'
,
'startDate'
,
'endDate'
,
'isRecurring'
]
},
{
key
:
'notices'
,
label
:
'Notices'
,
icon
:
Bell
,
endpoint
:
'/notices'
,
columns
:
[
'title'
,
'type'
,
'isBlocking'
]
},
{
key
:
'policies'
,
label
:
'Policies'
,
icon
:
BookOpen
,
endpoint
:
'/policies'
,
columns
:
[
'title'
,
'version'
,
'requiresAcknowledgment'
]
},
{
key
:
'api-keys'
,
label
:
'API Keys'
,
icon
:
Key
,
endpoint
:
'/api-keys'
,
columns
:
[
'name'
,
'scope'
,
'isActive'
]
},
{
key
:
'webhooks'
,
label
:
'Webhooks'
,
icon
:
Webhook
,
endpoint
:
'/webhooks'
,
columns
:
[
'url'
,
'isActive'
]
},
{
key
:
'audit'
,
label
:
'Audit Trail'
,
icon
:
Shield
,
endpoint
:
'/audit-trail'
,
columns
:
[
'action'
,
'entityType'
,
'method'
,
'ipAddress'
]
},
];
export
default
function
ControlPanelPage
()
{
const
[
activeEntity
,
setActiveEntity
]
=
useState
(
ENTITIES
[
0
]);
const
[
data
,
setData
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
any
>
(
null
);
useEffect
(()
=>
{
loadData
();
},
[
activeEntity
,
page
,
search
]);
const
loadData
=
async
()
=>
{
setIsLoading
(
true
);
try
{
const
params
:
any
=
{
page
,
limit
:
20
,
...(
activeEntity
.
params
||
{})
};
if
(
search
)
params
.
search
=
search
;
const
res
=
await
apiGet
(
activeEntity
.
endpoint
,
params
);
setData
(
res
.
data
||
[]);
setTotal
(
res
.
meta
?.
total
||
0
);
}
catch
(
err
)
{
console
.
error
(
'Failed to load data:'
,
err
);
setData
([]);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
try
{
await
apiDelete
(
`
${
activeEntity
.
endpoint
}
/
${
deleteTarget
.
id
}
`
);
toast
.
success
(
'Record deleted'
);
setDeleteTarget
(
null
);
loadData
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to delete'
);
}
};
const
renderCellValue
=
(
row
:
any
,
col
:
string
):
string
=>
{
const
val
=
row
[
col
];
if
(
val
===
null
||
val
===
undefined
)
return
'—'
;
if
(
typeof
val
===
'boolean'
)
return
val
?
'✅'
:
'❌'
;
if
(
col
.
includes
(
'Piasters'
)
||
col
.
includes
(
'piasters'
))
return
formatEgp
(
val
);
if
(
col
.
includes
(
'Date'
)
||
col
.
includes
(
'At'
))
{
try
{
return
formatDate
(
val
);
}
catch
{
return
String
(
val
);
}
}
if
(
typeof
val
===
'object'
)
return
JSON
.
stringify
(
val
).
slice
(
0
,
50
);
return
String
(
val
);
};
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Control Panel"
description=
"Super Admin — Full entity management (god mode)"
actions=
{
<
div
className=
"flex items-center gap-2"
>
<
Database
size=
{
16
}
className=
"text-muted-foreground"
/>
<
span
className=
"text-xs text-muted-foreground"
>
{
total
}
records
</
span
>
</
div
>
}
/>
{
/* Entity Tabs */
}
<
div
className=
"flex gap-1 overflow-x-auto pb-2"
>
{
ENTITIES
.
map
(
entity
=>
{
const
Icon
=
entity
.
icon
;
const
isActive
=
activeEntity
.
key
===
entity
.
key
;
return
(
<
button
key=
{
entity
.
key
}
onClick=
{
()
=>
{
setActiveEntity
(
entity
);
setPage
(
1
);
setSearch
(
''
);
}
}
className=
{
cn
(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors'
,
isActive
?
'bg-primary text-primary-foreground'
:
'hover:bg-accent text-muted-foreground'
,
)
}
>
<
Icon
size=
{
14
}
/>
{
entity
.
label
}
</
button
>
);
})
}
</
div
>
{
/* Search */
}
<
div
className=
"relative 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 ${activeEntity.label.toLowerCase()}
...
`
}
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
>
{
/* Table */
}
{
isLoading
?
(
<
PageLoadingSkeleton
/>
)
:
(
<
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 w-16"
>
ID
</
th
>
{
activeEntity
.
columns
.
map
(
col
=>
(
<
th
key=
{
col
}
className=
"text-left px-4 py-3 font-medium text-muted-foreground capitalize"
>
{
col
.
replace
(
/
([
A-Z
])
/g
,
' $1'
).
trim
()
}
</
th
>
))
}
<
th
className=
"text-left px-4 py-3 font-medium text-muted-foreground w-20"
>
Actions
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y"
>
{
data
.
map
((
row
)
=>
(
<
tr
key=
{
row
.
id
}
className=
"hover:bg-accent/50"
>
<
td
className=
"px-4 py-3 text-xs font-mono text-muted-foreground"
>
{
row
.
id
?.
slice
(
0
,
8
)
}
...</
td
>
{
activeEntity
.
columns
.
map
(
col
=>
(
<
td
key=
{
col
}
className=
"px-4 py-3 text-xs max-w-[200px] truncate"
>
{
col
===
'status'
||
col
===
'role'
||
col
===
'priority'
||
col
===
'type'
||
col
===
'category'
?
(
<
StatusBadge
status=
{
row
[
col
]
||
'—'
}
/>
)
:
(
renderCellValue
(
row
,
col
)
)
}
</
td
>
))
}
<
td
className=
"px-4 py-3"
>
{
activeEntity
.
key
!==
'audit'
&&
(
<
button
onClick=
{
()
=>
setDeleteTarget
(
row
)
}
className=
"p-1 text-muted-foreground hover:text-destructive"
>
<
Trash2
size=
{
14
}
/>
</
button
>
)
}
</
td
>
</
tr
>
))
}
{
data
.
length
===
0
&&
(
<
tr
>
<
td
colSpan=
{
activeEntity
.
columns
.
length
+
2
}
className=
"text-center py-12 text-muted-foreground"
>
No
{
activeEntity
.
label
.
toLowerCase
()
}
found.
</
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"
>
<
ChevronLeft
size=
{
14
}
/>
</
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"
>
<
ChevronRight
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
)
}
</
div
>
)
}
<
ConfirmDialog
open=
{
!!
deleteTarget
}
onClose=
{
()
=>
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
title=
{
`Delete ${activeEntity.label.slice(0, -1)}`
}
description=
{
`Permanently delete this record? This action cannot be undone.`
}
confirmLabel=
"Delete"
destructive
/>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/deductions/create/page.tsx
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
,
apiPost
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
toast
}
from
'sonner'
;
import
{
Send
,
Loader2
}
from
'lucide-react'
;
const
CATEGORIES
=
[
{
value
:
'A'
,
label
:
'A — Deadline Violations'
,
subs
:
[
{
value
:
'A1'
,
label
:
'A1 — Slight Delay (1-3 days)'
},
{
value
:
'A2'
,
label
:
'A2 — Moderate Delay (4-7 days)'
},
{
value
:
'A3'
,
label
:
'A3 — Severe Delay (8-14 days)'
},
{
value
:
'A4'
,
label
:
'A4 — Critical Delay (15+ days)'
},
{
value
:
'A5'
,
label
:
'A5 — Complete Failure'
},
]},
{
value
:
'B'
,
label
:
'B — Reporting Violations'
,
subs
:
[
{
value
:
'B1'
,
label
:
'B1 — Late Report'
},
{
value
:
'B2'
,
label
:
'B2 — Unreported Day'
},
{
value
:
'B3'
,
label
:
'B3 — Vague/Useless Report'
},
{
value
:
'B4'
,
label
:
'B4 — Falsified Report'
},
]},
{
value
:
'C'
,
label
:
'C — Quality Violations'
,
subs
:
[
{
value
:
'C1'
,
label
:
'C1 — Minor Quality Issues'
},
{
value
:
'C2'
,
label
:
'C2 — Significant Quality Issues'
},
{
value
:
'C3'
,
label
:
'C3 — Critical Quality Issues'
},
{
value
:
'C4'
,
label
:
'C4 — Regression'
},
]},
{
value
:
'D'
,
label
:
'D — Communication Violations'
,
subs
:
[
{
value
:
'D1'
,
label
:
'D1 — Slow Response'
},
{
value
:
'D2'
,
label
:
'D2 — No-Show Meeting'
},
{
value
:
'D3'
,
label
:
'D3 — Disappeared'
},
{
value
:
'D4'
,
label
:
'D4 — Unprofessional Conduct'
},
]},
];
export
default
function
CreateDeductionPage
()
{
const
router
=
useRouter
();
const
[
contractors
,
setContractors
]
=
useState
<
any
[]
>
([]);
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
userId
:
''
,
category
:
'A'
,
subCategory
:
'A1'
,
cardId
:
''
,
violationDate
:
new
Date
().
toISOString
().
split
(
'T'
)[
0
],
description
:
''
,
amountPiasters
:
0
,
});
useEffect
(()
=>
{
apiGet
(
'/users'
,
{
role
:
'CONTRACTOR'
,
status
:
'ACTIVE'
,
limit
:
100
})
.
then
(
res
=>
setContractors
(
res
.
data
||
[]))
.
catch
(
console
.
error
);
},
[]);
const
selectedCat
=
CATEGORIES
.
find
(
c
=>
c
.
value
===
form
.
category
);
const
handleSubmit
=
async
()
=>
{
if
(
!
form
.
userId
)
{
toast
.
error
(
'Select a contractor'
);
return
;
}
if
(
form
.
description
.
length
<
100
)
{
toast
.
error
(
'Description must be at least 100 characters'
);
return
;
}
setIsSubmitting
(
true
);
try
{
await
apiPost
(
'/deductions'
,
{
userId
:
form
.
userId
,
category
:
form
.
category
,
subCategory
:
form
.
subCategory
,
cardId
:
form
.
cardId
||
undefined
,
violationDate
:
form
.
violationDate
,
description
:
form
.
description
,
amountPiasters
:
form
.
amountPiasters
>
0
?
form
.
amountPiasters
:
undefined
,
});
toast
.
success
(
'Deduction created'
);
router
.
push
(
'/admin/deductions'
);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create deduction'
);
}
finally
{
setIsSubmitting
(
false
);
}
};
return
(
<
div
className=
"max-w-3xl mx-auto space-y-6"
>
<
PageHeader
title=
"Create Deduction"
description=
"Initiate a new deduction for a contractor"
/>
<
div
className=
"bg-card rounded-xl border p-6 space-y-5"
>
{
/* Contractor */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Contractor *
</
label
>
<
select
value=
{
form
.
userId
}
onChange=
{
e
=>
setForm
({
...
form
,
userId
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
""
>
Select contractor
</
option
>
{
contractors
.
map
(
c
=>
(
<
option
key=
{
c
.
id
}
value=
{
c
.
id
}
>
{
c
.
firstName
}
{
c
.
lastName
}
(@
{
c
.
username
}
)
</
option
>
))
}
</
select
>
</
div
>
{
/* Category */
}
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Category *
</
label
>
<
select
value=
{
form
.
category
}
onChange=
{
e
=>
{
const
cat
=
e
.
target
.
value
;
const
firstSub
=
CATEGORIES
.
find
(
c
=>
c
.
value
===
cat
)?.
subs
[
0
]?.
value
||
cat
+
'1'
;
setForm
({
...
form
,
category
:
cat
,
subCategory
:
firstSub
});
}
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{
CATEGORIES
.
map
(
c
=>
<
option
key=
{
c
.
value
}
value=
{
c
.
value
}
>
{
c
.
label
}
</
option
>)
}
</
select
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Sub-Category *
</
label
>
<
select
value=
{
form
.
subCategory
}
onChange=
{
e
=>
setForm
({
...
form
,
subCategory
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{
selectedCat
?.
subs
.
map
(
s
=>
<
option
key=
{
s
.
value
}
value=
{
s
.
value
}
>
{
s
.
label
}
</
option
>)
}
</
select
>
</
div
>
</
div
>
{
/* Date + Amount */
}
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Violation Date *
</
label
>
<
input
type=
"date"
value=
{
form
.
violationDate
}
onChange=
{
e
=>
setForm
({
...
form
,
violationDate
:
e
.
target
.
value
})
}
max=
{
new
Date
().
toISOString
().
split
(
'T'
)[
0
]
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Amount (piasters) — 0 = auto-calculate
</
label
>
<
input
type=
"number"
value=
{
form
.
amountPiasters
}
onChange=
{
e
=>
setForm
({
...
form
,
amountPiasters
:
Number
(
e
.
target
.
value
)
})
}
min=
{
0
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{
form
.
amountPiasters
>
0
&&
<
p
className=
"text-xs text-muted-foreground"
>
{
formatEgp
(
form
.
amountPiasters
)
}
</
p
>
}
</
div
>
</
div
>
{
/* Description */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Description * (min 100 chars)
</
label
>
<
textarea
value=
{
form
.
description
}
onChange=
{
e
=>
setForm
({
...
form
,
description
:
e
.
target
.
value
})
}
rows=
{
5
}
placeholder=
"Detailed explanation of the violation..."
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
<
p
className=
"text-xs text-muted-foreground"
>
{
form
.
description
.
length
}
/100 min characters
</
p
>
</
div
>
{
/* Actions */
}
<
div
className=
"flex justify-end gap-3 pt-4 border-t"
>
<
button
onClick=
{
()
=>
router
.
back
()
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleSubmit
}
disabled=
{
isSubmitting
}
className=
"flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50"
>
{
isSubmitting
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Send
size=
{
14
}
/>
}
Create Deduction
</
button
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/admin/invites/page.tsx
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
apiGet
,
apiPost
,
apiDelete
}
from
'@/lib/api'
;
import
{
PageHeader
}
from
'@/components/shared/page-header'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
EmptyState
}
from
'@/components/shared/empty-state'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
formatDate
,
relativeTime
}
from
'@/lib/date'
;
import
{
Send
,
Plus
,
Copy
,
Trash2
,
Loader2
,
UserPlus
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
InvitesPage
()
{
const
[
invites
,
setInvites
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
isCreating
,
setIsCreating
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
contractorType
:
'FULL_TIME'
,
expiresInDays
:
7
,
welcomeNote
:
''
,
});
useEffect
(()
=>
{
loadInvites
();
},
[]);
const
loadInvites
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
'/onboarding/invites'
,
{
limit
:
100
});
setInvites
(
res
.
data
||
[]);
}
catch
(
err
)
{
console
.
error
(
'Failed to load invites:'
,
err
);
}
finally
{
setIsLoading
(
false
);
}
};
const
handleCreate
=
async
()
=>
{
setIsCreating
(
true
);
try
{
const
res
=
await
apiPost
(
'/onboarding/invites'
,
form
);
const
code
=
res
.
data
?.
code
||
res
.
data
?.
inviteCode
;
toast
.
success
(
`Invite created:
${
code
}
`
,
{
duration
:
15000
});
setShowCreate
(
false
);
loadInvites
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create invite'
);
}
finally
{
setIsCreating
(
false
);
}
};
const
handleRevoke
=
async
(
id
:
string
)
=>
{
try
{
await
apiDelete
(
`/onboarding/invites/
${
id
}
`
);
toast
.
success
(
'Invite revoked'
);
loadInvites
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to revoke'
);
}
};
const
copyLink
=
(
code
:
string
)
=>
{
const
url
=
`
${
window
.
location
.
origin
}
/register/
${
code
}
`
;
navigator
.
clipboard
.
writeText
(
url
);
toast
.
success
(
'Invite link copied!'
);
};
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
return
(
<
div
className=
"space-y-6"
>
<
PageHeader
title=
"Invite Management"
description=
"Create and manage contractor invitations"
actions=
{
<
button
onClick=
{
()
=>
setShowCreate
(
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
}
/>
Create Invite
</
button
>
}
/>
{
showCreate
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4"
>
<
h3
className=
"font-semibold"
>
New Invitation
</
h3
>
<
div
className=
"grid gap-4 sm:grid-cols-3"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Contractor Type *
</
label
>
<
select
value=
{
form
.
contractorType
}
onChange=
{
e
=>
setForm
({
...
form
,
contractorType
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"FULL_TIME"
>
Full-Timer
</
option
>
<
option
value=
"INTERN"
>
Intern
</
option
>
</
select
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Expires In (days)
</
label
>
<
input
type=
"number"
value=
{
form
.
expiresInDays
}
onChange=
{
e
=>
setForm
({
...
form
,
expiresInDays
:
Number
(
e
.
target
.
value
)
})
}
min=
{
1
}
max=
{
30
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Welcome Note
</
label
>
<
input
type=
"text"
value=
{
form
.
welcomeNote
}
onChange=
{
e
=>
setForm
({
...
form
,
welcomeNote
:
e
.
target
.
value
})
}
placeholder=
"Optional message"
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
</
div
>
<
div
className=
"flex justify-end gap-2"
>
<
button
onClick=
{
()
=>
setShowCreate
(
false
)
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</
button
>
<
button
onClick=
{
handleCreate
}
disabled=
{
isCreating
}
className=
"flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50"
>
{
isCreating
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Send
size=
{
14
}
/>
}
Create
</
button
>
</
div
>
</
div
>
)
}
{
invites
.
length
===
0
?
(
<
EmptyState
icon=
{
UserPlus
}
title=
"No invitations"
description=
"Create an invite to onboard a new contractor."
/>
)
:
(
<
div
className=
"bg-card rounded-xl border divide-y"
>
{
invites
.
map
(
inv
=>
(
<
div
key=
{
inv
.
id
}
className=
"p-4 flex items-center justify-between"
>
<
div
>
<
div
className=
"flex items-center gap-2"
>
<
code
className=
"text-sm font-mono bg-muted px-2 py-0.5 rounded"
>
{
inv
.
code
||
inv
.
inviteCode
}
</
code
>
<
StatusBadge
status=
{
inv
.
status
||
'ACTIVE'
}
/>
<
span
className=
"text-xs text-muted-foreground"
>
{
inv
.
contractorType
?.
replace
(
'_'
,
' '
)
}
</
span
>
</
div
>
<
p
className=
"text-xs text-muted-foreground mt-1"
>
Created
{
inv
.
createdAt
?
relativeTime
(
inv
.
createdAt
)
:
'—'
}
{
inv
.
expiresAt
&&
` · Expires ${formatDate(inv.expiresAt)}`
}
{
inv
.
usedBy
&&
` · Used by ${inv.usedBy.firstName} ${inv.usedBy.lastName}`
}
</
p
>
</
div
>
<
div
className=
"flex gap-1"
>
{
(
inv
.
status
===
'ACTIVE'
||
!
inv
.
status
)
&&
(
<>
<
button
onClick=
{
()
=>
copyLink
(
inv
.
code
||
inv
.
inviteCode
)
}
className=
"p-2 text-muted-foreground hover:text-foreground"
title=
"Copy link"
><
Copy
size=
{
14
}
/></
button
>
<
button
onClick=
{
()
=>
handleRevoke
(
inv
.
id
)
}
className=
"p-2 text-muted-foreground hover:text-destructive"
title=
"Revoke"
><
Trash2
size=
{
14
}
/></
button
>
</>
)
}
</
div
>
</
div
>
))
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(dashboard)/boards/[boardId]/calendar/page.tsx
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useEffect
,
useState
,
useMemo
}
from
'react'
;
import
{
useParams
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
PageLoadingSkeleton
}
from
'@/components/shared/loading-skeleton'
;
import
{
BoardHeader
}
from
'@/components/kanban/board-header'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
formatEgp
,
cn
}
from
'@/lib/utils'
;
import
{
ChevronLeft
,
ChevronRight
,
Coins
,
Clock
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
export
default
function
BoardCalendarPage
()
{
const
{
boardId
}
=
useParams
<
{
boardId
:
string
}
>
();
const
[
board
,
setBoard
]
=
useState
<
any
>
(
null
);
const
[
cards
,
setCards
]
=
useState
<
any
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
currentDate
,
setCurrentDate
]
=
useState
(
new
Date
());
useEffect
(()
=>
{
loadData
();
},
[
boardId
]);
const
loadData
=
async
()
=>
{
try
{
const
[
boardRes
,
cardsRes
]
=
await
Promise
.
all
([
apiGet
(
`/boards/
${
boardId
}
`
),
apiGet
(
'/cards'
,
{
boardId
,
limit
:
500
,
isArchived
:
false
}),
]);
setBoard
(
boardRes
.
data
);
setCards
(
cardsRes
.
data
||
[]);
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to load board'
);
}
finally
{
setIsLoading
(
false
);
}
};
const
year
=
currentDate
.
getFullYear
();
const
month
=
currentDate
.
getMonth
();
const
calendarDays
=
useMemo
(()
=>
{
const
firstDay
=
new
Date
(
year
,
month
,
1
);
const
lastDay
=
new
Date
(
year
,
month
+
1
,
0
);
const
startDayOfWeek
=
firstDay
.
getDay
();
const
totalDays
=
lastDay
.
getDate
();
const
days
:
{
date
:
Date
|
null
;
cards
:
any
[]
}[]
=
[];
// Padding for days before the 1st
for
(
let
i
=
0
;
i
<
startDayOfWeek
;
i
++
)
{
days
.
push
({
date
:
null
,
cards
:
[]
});
}
// Days of the month
for
(
let
d
=
1
;
d
<=
totalDays
;
d
++
)
{
const
date
=
new
Date
(
year
,
month
,
d
);
const
dateStr
=
date
.
toISOString
().
split
(
'T'
)[
0
];
const
dayCards
=
cards
.
filter
(
card
=>
{
if
(
!
card
.
dueDate
)
return
false
;
const
dueDateStr
=
new
Date
(
card
.
dueDate
).
toISOString
().
split
(
'T'
)[
0
];
return
dueDateStr
===
dateStr
;
});
days
.
push
({
date
,
cards
:
dayCards
});
}
return
days
;
},
[
cards
,
year
,
month
]);
const
prevMonth
=
()
=>
setCurrentDate
(
new
Date
(
year
,
month
-
1
,
1
));
const
nextMonth
=
()
=>
setCurrentDate
(
new
Date
(
year
,
month
+
1
,
1
));
const
today
=
new
Date
();
const
isToday
=
(
date
:
Date
|
null
)
=>
date
&&
date
.
toDateString
()
===
today
.
toDateString
();
const
isPast
=
(
date
:
Date
|
null
)
=>
date
&&
date
<
today
&&
!
isToday
(
date
);
if
(
isLoading
)
return
<
PageLoadingSkeleton
/>;
if
(
!
board
)
return
<
div
className=
"p-6 text-muted-foreground"
>
Board not found.
</
div
>;
return
(
<
div
className=
"space-y-4"
>
<
BoardHeader
board=
{
board
}
onRefresh=
{
loadData
}
/>
{
/* Month Navigation */
}
<
div
className=
"flex items-center justify-between"
>
<
button
onClick=
{
prevMonth
}
className=
"p-2 rounded-md hover:bg-accent"
><
ChevronLeft
size=
{
16
}
/></
button
>
<
h2
className=
"text-lg font-bold"
>
{
currentDate
.
toLocaleString
(
'default'
,
{
month
:
'long'
,
year
:
'numeric'
})
}
</
h2
>
<
button
onClick=
{
nextMonth
}
className=
"p-2 rounded-md hover:bg-accent"
><
ChevronRight
size=
{
16
}
/></
button
>
</
div
>
{
/* Calendar Grid */
}
<
div
className=
"bg-card rounded-xl border overflow-hidden"
>
{
/* Day Headers */
}
<
div
className=
"grid grid-cols-7 border-b"
>
{
[
'Sun'
,
'Mon'
,
'Tue'
,
'Wed'
,
'Thu'
,
'Fri'
,
'Sat'
].
map
(
day
=>
(
<
div
key=
{
day
}
className=
"px-2 py-2 text-xs font-medium text-muted-foreground text-center bg-muted/50"
>
{
day
}
</
div
>
))
}
</
div
>
{
/* Calendar Cells */
}
<
div
className=
"grid grid-cols-7"
>
{
calendarDays
.
map
((
day
,
i
)
=>
(
<
div
key=
{
i
}
className=
{
cn
(
'min-h-[100px] border-b border-r p-1.5 transition-colors'
,
!
day
.
date
&&
'bg-muted/20'
,
isToday
(
day
.
date
)
&&
'bg-primary/5'
,
isPast
(
day
.
date
)
&&
'opacity-60'
,
)
}
>
{
day
.
date
&&
(
<>
<
div
className=
{
cn
(
'text-xs font-medium mb-1'
,
isToday
(
day
.
date
)
&&
'text-primary font-bold'
,
)
}
>
{
day
.
date
.
getDate
()
}
</
div
>
<
div
className=
"space-y-0.5"
>
{
day
.
cards
.
slice
(
0
,
3
).
map
(
card
=>
{
const
isOverdue
=
!
card
.
completedAt
&&
new
Date
(
card
.
dueDate
)
<
today
;
return
(
<
div
key=
{
card
.
id
}
className=
{
cn
(
'text-[10px] px-1.5 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity'
,
isOverdue
?
'bg-red-500/10 text-red-600'
:
card
.
completedAt
?
'bg-emerald-500/10 text-emerald-600 line-through'
:
'bg-blue-500/10 text-blue-600'
,
)
}
title=
{
`${card.cardNumber}: ${card.title}`
}
>
<
span
className=
"font-mono"
>
{
card
.
cardNumber
}
</
span
>
{
card
.
title
}
</
div
>
);
})
}
{
day
.
cards
.
length
>
3
&&
(
<
div
className=
"text-[10px] text-muted-foreground px-1.5"
>
+
{
day
.
cards
.
length
-
3
}
more
</
div
>
)
}
</
div
>
</>
)
}
</
div
>
))
}
</
div
>
</
div
>
{
/* Legend */
}
<
div
className=
"flex items-center gap-4 text-xs text-muted-foreground"
>
<
span
className=
"flex items-center gap-1"
><
span
className=
"w-3 h-3 rounded bg-blue-500/20"
/>
Due
</
span
>
<
span
className=
"flex items-center gap-1"
><
span
className=
"w-3 h-3 rounded bg-red-500/20"
/>
Overdue
</
span
>
<
span
className=
"flex items-center gap-1"
><
span
className=
"w-3 h-3 rounded bg-emerald-500/20"
/>
Completed
</
span
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/layout/topbar.tsx
View file @
731cd6e6
...
...
@@ -5,7 +5,10 @@ import { Bell, MessageSquare, Search, LogOut, User, Moon, Sun } from 'lucide-rea
import
{
useTheme
}
from
'next-themes'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
useNotificationStore
}
from
'@/stores/notification.store'
;
import
{
useSearchStore
}
from
'@/stores/search.store'
;
import
{
HudBar
}
from
'@/components/hud/hud-bar'
;
import
{
CommandPalette
}
from
'@/components/search/command-palette'
;
import
{
useKeyboardShortcut
}
from
'@/hooks/use-keyboard-shortcut'
;
import
{
cn
}
from
'@/lib/utils'
;
export
function
Topbar
()
{
...
...
@@ -13,10 +16,15 @@ export function Topbar() {
const
{
user
,
logout
}
=
useAuthStore
();
const
{
unreadCount
}
=
useNotificationStore
();
const
{
theme
,
setTheme
}
=
useTheme
();
const
{
isOpen
,
openSearch
,
closeSearch
,
toggleSearch
}
=
useSearchStore
();
const
isContractor
=
user
?.
role
===
'CONTRACTOR'
;
// Ctrl+K / Cmd+K to open search
useKeyboardShortcut
(
'k'
,
()
=>
toggleSearch
(),
{
ctrl
:
true
});
return
(
<>
<
header
className=
"sticky top-0 z-30 h-14 bg-background/80 backdrop-blur-sm border-b flex items-center justify-between px-6"
>
{
/* Left: HUD (for contractors) or search */
}
<
div
className=
"flex items-center gap-4 flex-1"
>
...
...
@@ -24,17 +32,31 @@ export function Topbar() {
<
HudBar
/>
)
:
(
<
button
onClick=
{
()
=>
router
.
push
(
'/admin/analytics'
)
}
onClick=
{
openSearch
}
className=
"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<
Search
size=
{
16
}
/>
<
span
className=
"hidden sm:inline"
>
Search... (Ctrl+K)
</
span
>
<
span
className=
"hidden sm:inline"
>
Search...
</
span
>
<
kbd
className=
"hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] bg-muted rounded border font-mono"
>
⌘K
</
kbd
>
</
button
>
)
}
</
div
>
{
/* Right: Actions */
}
<
div
className=
"flex items-center gap-1"
>
{
/* Search button for contractors too */
}
{
isContractor
&&
(
<
button
onClick=
{
openSearch
}
className=
"p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title=
"Search (Ctrl+K)"
>
<
Search
size=
{
18
}
/>
</
button
>
)
}
{
/* Theme toggle */
}
<
button
onClick=
{
()
=>
setTheme
(
theme
===
'dark'
?
'light'
:
'dark'
)
}
...
...
@@ -86,5 +108,9 @@ export function Topbar() {
</
div
>
</
div
>
</
header
>
{
/* Command Palette */
}
<
CommandPalette
open=
{
isOpen
}
onClose=
{
closeSearch
}
/>
</>
);
}
\ No newline at end of file
frontend/src/components/search/command-palette.tsx
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
Search
,
Kanban
,
ListTodo
,
FileText
,
Users
,
DollarSign
,
MessageSquare
,
Bell
,
Settings
,
User
,
X
,
Loader2
,
Star
,
Calendar
,
GraduationCap
,
}
from
'lucide-react'
;
const
QUICK_ACTIONS
=
[
{
label
:
'Submit Report'
,
href
:
'/reports/submit'
,
icon
:
FileText
,
keywords
:
[
'report'
,
'submit'
,
'daily'
]
},
{
label
:
'My Tasks'
,
href
:
'/my-tasks'
,
icon
:
ListTodo
,
keywords
:
[
'tasks'
,
'my tasks'
,
'assigned'
]
},
{
label
:
'Boards'
,
href
:
'/boards'
,
icon
:
Kanban
,
keywords
:
[
'boards'
,
'kanban'
,
'projects'
]
},
{
label
:
'Messages'
,
href
:
'/messages'
,
icon
:
MessageSquare
,
keywords
:
[
'messages'
,
'dm'
,
'chat'
]
},
{
label
:
'Notifications'
,
href
:
'/notifications'
,
icon
:
Bell
,
keywords
:
[
'notifications'
,
'alerts'
]
},
{
label
:
'Salary'
,
href
:
'/salary'
,
icon
:
DollarSign
,
keywords
:
[
'salary'
,
'pay'
,
'money'
,
'hud'
]
},
{
label
:
'Profile'
,
href
:
'/profile'
,
icon
:
User
,
keywords
:
[
'profile'
,
'me'
,
'settings'
]
},
{
label
:
'Directory'
,
href
:
'/directory'
,
icon
:
Users
,
keywords
:
[
'directory'
,
'people'
,
'team'
]
},
{
label
:
'Schedule'
,
href
:
'/schedule'
,
icon
:
Calendar
,
keywords
:
[
'schedule'
,
'calendar'
]
},
{
label
:
'Evaluations'
,
href
:
'/evaluations'
,
icon
:
Star
,
keywords
:
[
'evaluations'
,
'eval'
,
'review'
]
},
{
label
:
'Learning'
,
href
:
'/learning'
,
icon
:
GraduationCap
,
keywords
:
[
'learning'
,
'goals'
,
'competency'
]
},
];
const
ADMIN_ACTIONS
=
[
{
label
:
'Contractors'
,
href
:
'/admin/contractors'
,
icon
:
Users
,
keywords
:
[
'contractors'
,
'manage'
,
'admin'
]
},
{
label
:
'Deductions'
,
href
:
'/admin/deductions'
,
icon
:
DollarSign
,
keywords
:
[
'deductions'
,
'admin'
]
},
{
label
:
'Payroll'
,
href
:
'/admin/payroll'
,
icon
:
DollarSign
,
keywords
:
[
'payroll'
,
'payment'
]
},
{
label
:
'Analytics'
,
href
:
'/admin/analytics'
,
icon
:
Star
,
keywords
:
[
'analytics'
,
'reports'
,
'dashboard'
]
},
{
label
:
'Settings'
,
href
:
'/admin/settings'
,
icon
:
Settings
,
keywords
:
[
'settings'
,
'config'
]
},
{
label
:
'Audit Trail'
,
href
:
'/admin/audit-trail'
,
icon
:
FileText
,
keywords
:
[
'audit'
,
'trail'
,
'log'
]
},
{
label
:
'Invites'
,
href
:
'/admin/invites'
,
icon
:
Users
,
keywords
:
[
'invites'
,
'onboarding'
]
},
];
interface
CommandPaletteProps
{
open
:
boolean
;
onClose
:
()
=>
void
;
}
export
function
CommandPalette
({
open
,
onClose
}:
CommandPaletteProps
)
{
const
router
=
useRouter
();
const
user
=
useAuthStore
(
s
=>
s
.
user
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
isSearching
,
setIsSearching
]
=
useState
(
false
);
const
[
searchResults
,
setSearchResults
]
=
useState
<
any
[]
>
([]);
const
[
selectedIndex
,
setSelectedIndex
]
=
useState
(
0
);
const
isAdmin
=
user
?.
role
===
'SUPER_ADMIN'
||
user
?.
role
===
'ADMIN'
;
// Reset on open
useEffect
(()
=>
{
if
(
open
)
{
setQuery
(
''
);
setSearchResults
([]);
setSelectedIndex
(
0
);
}
},
[
open
]);
// Search API debounced
useEffect
(()
=>
{
if
(
query
.
length
<
2
)
{
setSearchResults
([]);
return
;
}
const
timer
=
setTimeout
(
async
()
=>
{
setIsSearching
(
true
);
try
{
const
res
=
await
apiGet
(
'/search'
,
{
q
:
query
,
limit
:
10
});
setSearchResults
(
res
.
data
||
[]);
}
catch
{
/* ok */
}
finally
{
setIsSearching
(
false
);
}
},
300
);
return
()
=>
clearTimeout
(
timer
);
},
[
query
]);
// Filtered quick actions
const
q
=
query
.
toLowerCase
();
const
filteredActions
=
[...
QUICK_ACTIONS
,
...(
isAdmin
?
ADMIN_ACTIONS
:
[])].
filter
(
action
=>
!
query
||
action
.
label
.
toLowerCase
().
includes
(
q
)
||
action
.
keywords
.
some
(
k
=>
k
.
includes
(
q
))
);
// All items for keyboard nav
const
allItems
=
[
...
filteredActions
.
map
(
a
=>
({
type
:
'action'
as
const
,
...
a
})),
...
searchResults
.
map
(
r
=>
({
type
:
'result'
as
const
,
...
r
})),
];
const
handleSelect
=
(
item
:
any
)
=>
{
if
(
item
.
type
===
'action'
)
{
router
.
push
(
item
.
href
);
}
else
if
(
item
.
type
===
'result'
)
{
if
(
item
.
entityType
===
'cards'
)
router
.
push
(
`/boards/
${
item
.
boardId
||
''
}
`
);
else
if
(
item
.
entityType
===
'users'
)
router
.
push
(
`/admin/contractors/
${
item
.
id
}
`
);
else
if
(
item
.
entityType
===
'boards'
)
router
.
push
(
`/boards/
${
item
.
id
}
`
);
else
if
(
item
.
href
)
router
.
push
(
item
.
href
);
}
onClose
();
};
// Keyboard navigation
const
handleKeyDown
=
useCallback
((
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'ArrowDown'
)
{
e
.
preventDefault
();
setSelectedIndex
(
i
=>
Math
.
min
(
i
+
1
,
allItems
.
length
-
1
));
}
else
if
(
e
.
key
===
'ArrowUp'
)
{
e
.
preventDefault
();
setSelectedIndex
(
i
=>
Math
.
max
(
i
-
1
,
0
));
}
else
if
(
e
.
key
===
'Enter'
&&
allItems
[
selectedIndex
])
{
e
.
preventDefault
();
handleSelect
(
allItems
[
selectedIndex
]);
}
else
if
(
e
.
key
===
'Escape'
)
{
onClose
();
}
},
[
allItems
,
selectedIndex
,
onClose
]);
if
(
!
open
)
return
null
;
return
(
<
div
className=
"fixed inset-0 z-[60] bg-black/50 flex items-start justify-center pt-[15vh] p-4"
onClick=
{
onClose
}
>
<
div
className=
"bg-card rounded-xl border shadow-2xl max-w-xl w-full overflow-hidden"
onClick=
{
e
=>
e
.
stopPropagation
()
}
>
{
/* Input */
}
<
div
className=
"flex items-center gap-3 px-4 border-b"
>
<
Search
size=
{
18
}
className=
"text-muted-foreground shrink-0"
/>
<
input
type=
"text"
value=
{
query
}
onChange=
{
e
=>
{
setQuery
(
e
.
target
.
value
);
setSelectedIndex
(
0
);
}
}
onKeyDown=
{
handleKeyDown
}
placeholder=
"Search or type a command..."
autoFocus
className=
"w-full py-3.5 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{
isSearching
&&
<
Loader2
size=
{
16
}
className=
"animate-spin text-muted-foreground"
/>
}
<
button
onClick=
{
onClose
}
className=
"p-1 text-muted-foreground hover:text-foreground"
>
<
X
size=
{
16
}
/>
</
button
>
</
div
>
{
/* Results */
}
<
div
className=
"max-h-80 overflow-y-auto"
>
{
/* Quick Actions */
}
{
filteredActions
.
length
>
0
&&
(
<
div
className=
"p-2"
>
<
p
className=
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
{
query
?
'Matching Actions'
:
'Quick Actions'
}
</
p
>
{
filteredActions
.
map
((
action
,
i
)
=>
{
const
Icon
=
action
.
icon
;
const
isSelected
=
i
===
selectedIndex
;
return
(
<
button
key=
{
action
.
href
}
onClick=
{
()
=>
handleSelect
({
type
:
'action'
,
...
action
})
}
className=
{
`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`
}
>
<
Icon
size=
{
16
}
className=
"text-muted-foreground shrink-0"
/>
<
span
>
{
action
.
label
}
</
span
>
</
button
>
);
})
}
</
div
>
)
}
{
/* API Results */
}
{
searchResults
.
length
>
0
&&
(
<
div
className=
"p-2 border-t"
>
<
p
className=
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Search Results
</
p
>
{
searchResults
.
map
((
result
,
i
)
=>
{
const
globalIndex
=
filteredActions
.
length
+
i
;
const
isSelected
=
globalIndex
===
selectedIndex
;
return
(
<
button
key=
{
`${result.entityType}-${result.id}`
}
onClick=
{
()
=>
handleSelect
({
type
:
'result'
,
...
result
})
}
className=
{
`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`
}
>
<
span
className=
"text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground shrink-0"
>
{
result
.
entityType
}
</
span
>
<
div
className=
"min-w-0"
>
<
p
className=
"truncate font-medium"
>
{
result
.
title
||
result
.
name
||
result
.
cardNumber
||
result
.
id
?.
slice
(
0
,
8
)
}
</
p
>
{
result
.
snippet
&&
<
p
className=
"text-xs text-muted-foreground truncate"
>
{
result
.
snippet
}
</
p
>
}
</
div
>
</
button
>
);
})
}
</
div
>
)
}
{
query
.
length
>=
2
&&
searchResults
.
length
===
0
&&
!
isSearching
&&
(
<
p
className=
"text-center py-8 text-sm text-muted-foreground"
>
No results for "
{
query
}
"
</
p
>
)
}
</
div
>
{
/* Footer */
}
<
div
className=
"px-4 py-2 border-t text-[10px] text-muted-foreground flex items-center gap-4"
>
<
span
>
↑↓ Navigate
</
span
>
<
span
>
↵ Select
</
span
>
<
span
>
Esc Close
</
span
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/hooks/use-keyboard-shortcut.ts
0 → 100644
View file @
731cd6e6
'use client'
;
import
{
useEffect
,
useCallback
}
from
'react'
;
type
KeyHandler
=
(
event
:
KeyboardEvent
)
=>
void
;
interface
ShortcutOptions
{
ctrl
?:
boolean
;
meta
?:
boolean
;
shift
?:
boolean
;
alt
?:
boolean
;
enabled
?:
boolean
;
}
export
function
useKeyboardShortcut
(
key
:
string
,
handler
:
KeyHandler
,
options
:
ShortcutOptions
=
{},
):
void
{
const
{
ctrl
,
meta
,
shift
,
alt
,
enabled
=
true
}
=
options
;
const
handleKeyDown
=
useCallback
(
(
event
:
KeyboardEvent
)
=>
{
if
(
!
enabled
)
return
;
// Don't fire shortcuts when typing in inputs
const
target
=
event
.
target
as
HTMLElement
;
if
(
target
.
tagName
===
'INPUT'
||
target
.
tagName
===
'TEXTAREA'
||
target
.
tagName
===
'SELECT'
||
target
.
isContentEditable
)
{
// Exception: allow Escape and Ctrl/Cmd+K even in inputs
const
isEscape
=
event
.
key
===
'Escape'
;
const
isCmdK
=
(
event
.
metaKey
||
event
.
ctrlKey
)
&&
event
.
key
.
toLowerCase
()
===
'k'
;
if
(
!
isEscape
&&
!
isCmdK
)
return
;
}
const
keyMatch
=
event
.
key
.
toLowerCase
()
===
key
.
toLowerCase
();
const
ctrlMatch
=
ctrl
?
(
event
.
ctrlKey
||
event
.
metaKey
)
:
true
;
const
metaMatch
=
meta
?
event
.
metaKey
:
true
;
const
shiftMatch
=
shift
?
event
.
shiftKey
:
!
event
.
shiftKey
;
const
altMatch
=
alt
?
event
.
altKey
:
!
event
.
altKey
;
// For Ctrl/Cmd shortcuts, require the modifier
if
((
ctrl
||
meta
)
&&
!
(
event
.
ctrlKey
||
event
.
metaKey
))
return
;
if
(
keyMatch
&&
ctrlMatch
&&
metaMatch
&&
shiftMatch
&&
altMatch
)
{
event
.
preventDefault
();
handler
(
event
);
}
},
[
key
,
handler
,
ctrl
,
meta
,
shift
,
alt
,
enabled
],
);
useEffect
(()
=>
{
window
.
addEventListener
(
'keydown'
,
handleKeyDown
);
return
()
=>
window
.
removeEventListener
(
'keydown'
,
handleKeyDown
);
},
[
handleKeyDown
]);
}
\ No newline at end of file
frontend/src/stores/search.store.ts
0 → 100644
View file @
731cd6e6
import
{
create
}
from
'zustand'
;
interface
SearchState
{
isOpen
:
boolean
;
openSearch
:
()
=>
void
;
closeSearch
:
()
=>
void
;
toggleSearch
:
()
=>
void
;
}
export
const
useSearchStore
=
create
<
SearchState
>
((
set
)
=>
({
isOpen
:
false
,
openSearch
:
()
=>
set
({
isOpen
:
true
}),
closeSearch
:
()
=>
set
({
isOpen
:
false
}),
toggleSearch
:
()
=>
set
((
state
)
=>
({
isOpen
:
!
state
.
isOpen
})),
}));
\ 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