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
0a82b230
Commit
0a82b230
authored
Apr 02, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 12 files via Son of Anton
parent
553c8c4a
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
1516 additions
and
110 deletions
+1516
-110
layout.tsx
frontend/src/app/(dashboard)/layout.tsx
+28
-0
layout.tsx
frontend/src/app/(onboarding)/layout.tsx
+55
-0
page.tsx
frontend/src/app/(onboarding)/onboarding/page.tsx
+147
-0
hud-expanded.tsx
frontend/src/components/hud/hud-expanded.tsx
+108
-0
salary-animation.tsx
frontend/src/components/hud/salary-animation.tsx
+89
-0
board-filters.tsx
frontend/src/components/kanban/board-filters.tsx
+373
-0
column-header.tsx
frontend/src/components/kanban/column-header.tsx
+71
-0
create-card-dialog.tsx
frontend/src/components/kanban/create-card-dialog.tsx
+423
-0
keyboard-shortcuts-help.tsx
frontend/src/components/shared/keyboard-shortcuts-help.tsx
+100
-0
offline-banner.tsx
frontend/src/components/shared/offline-banner.tsx
+23
-0
use-navigation-shortcuts.ts
frontend/src/hooks/use-navigation-shortcuts.ts
+94
-0
use-online-status.ts
frontend/src/hooks/use-online-status.ts
+5
-110
No files found.
frontend/src/app/(dashboard)/layout.tsx
View file @
0a82b230
...
...
@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth.store';
import
{
Sidebar
}
from
'@/components/layout/sidebar'
;
import
{
Topbar
}
from
'@/components/layout/topbar'
;
import
{
BlockingOverlay
}
from
'@/components/notifications/blocking-overlay'
;
import
{
OfflineBanner
}
from
'@/components/shared/offline-banner'
;
import
{
KeyboardShortcutsHelp
}
from
'@/components/shared/keyboard-shortcuts-help'
;
import
{
useHud
}
from
'@/hooks/use-hud'
;
import
{
useNotifications
}
from
'@/hooks/use-notifications'
;
import
{
useNavigationShortcuts
}
from
'@/hooks/use-navigation-shortcuts'
;
import
{
useThemeStore
}
from
'@/stores/theme.store'
;
import
{
cn
}
from
'@/lib/utils'
;
...
...
@@ -35,9 +38,28 @@ export default function DashboardLayout({
}
},
[
isAuthenticated
,
router
]);
// Redirect onboarding users
useEffect
(()
=>
{
if
(
user
&&
user
.
status
===
'ONBOARDING'
)
{
router
.
push
(
'/onboarding'
);
}
},
[
user
,
router
]);
// Initialize HUD and notifications
useHud
();
useNotifications
();
useNavigationShortcuts
();
// Theme toggle listener
useEffect
(()
=>
{
const
handler
=
()
=>
{
const
html
=
document
.
documentElement
;
const
isDark
=
html
.
classList
.
contains
(
'dark'
);
html
.
classList
.
toggle
(
'dark'
,
!
isDark
);
};
document
.
addEventListener
(
'toggle-theme'
,
handler
);
return
()
=>
document
.
removeEventListener
(
'toggle-theme'
,
handler
);
},
[]);
if
(
!
user
)
{
return
(
...
...
@@ -52,6 +74,12 @@ export default function DashboardLayout({
{
/* Blocking notification overlay */
}
<
BlockingOverlay
/>
{
/* Offline banner */
}
<
OfflineBanner
/>
{
/* Keyboard shortcuts help */
}
<
KeyboardShortcutsHelp
/>
{
/* Sidebar */
}
<
Sidebar
/>
...
...
frontend/src/app/(onboarding)/layout.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useEffect
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
export
default
function
OnboardingLayout
({
children
,
}:
{
children
:
React
.
ReactNode
;
})
{
const
{
user
,
isAuthenticated
,
loadUser
}
=
useAuthStore
();
const
router
=
useRouter
();
useEffect
(()
=>
{
loadUser
();
},
[
loadUser
]);
useEffect
(()
=>
{
if
(
!
isAuthenticated
)
{
const
token
=
localStorage
.
getItem
(
'accessToken'
);
if
(
!
token
)
{
router
.
push
(
'/login'
);
}
}
},
[
isAuthenticated
,
router
]);
useEffect
(()
=>
{
if
(
user
&&
user
.
status
!==
'ONBOARDING'
)
{
router
.
push
(
'/'
);
}
},
[
user
,
router
]);
if
(
!
user
)
{
return
(
<
div
className=
"min-h-screen flex items-center justify-center"
>
<
div
className=
"animate-spin rounded-full h-8 w-8 border-b-2 border-primary"
/>
</
div
>
);
}
return
(
<
div
className=
"min-h-screen bg-gradient-to-br from-background to-muted"
>
<
div
className=
"max-w-3xl mx-auto p-6"
>
<
div
className=
"text-center mb-8"
>
<
h1
className=
"text-3xl font-black tracking-tighter"
>
THE GRIND
</
h1
>
<
p
className=
"text-sm text-muted-foreground mt-1"
>
Welcome,
{
user
.
firstName
}
! Complete your onboarding to get started.
</
p
>
</
div
>
{
children
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/app/(onboarding)/onboarding/page.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
CheckCircle2
,
Clock
,
AlertCircle
,
Loader2
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
ChecklistItem
{
id
:
string
;
name
:
string
;
status
:
'COMPLETE'
|
'PENDING'
|
'NOT_STARTED'
;
verifiedBy
:
string
;
completedAt
?:
string
;
}
export
default
function
OnboardingChecklistPage
()
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
router
=
useRouter
();
const
[
items
,
setItems
]
=
useState
<
ChecklistItem
[]
>
([]);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
loadChecklist
();
},
[]);
const
loadChecklist
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/onboarding/checklist/
${
user
?.
id
}
`);
setItems(res.data || getDefaultChecklist());
} catch {
setItems(getDefaultChecklist());
} finally {
setIsLoading(false);
}
};
const getDefaultChecklist = (): ChecklistItem[] => [
{ id: '1', name: 'Profile photo uploaded', status: user?.avatar ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '2', name: 'Bank details provided', status: user?.bankName ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '3', name: 'Contract signed', status: user?.contractSignedAt ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '4', name: 'All policies acknowledged', status: 'PENDING', verifiedBy: 'System' },
{ id: '5', name: 'Competency self-assessment completed', status: 'PENDING', verifiedBy: 'System' },
{ id: '6', name: 'Device setup confirmed', status: 'NOT_STARTED', verifiedBy: 'Contractor' },
{ id: '7', name: 'Source control access configured', status: 'NOT_STARTED', verifiedBy: 'Project Leader' },
{ id: '8', name: 'First board assigned', status: 'NOT_STARTED', verifiedBy: 'Admin / PL' },
{ id: '9', name: 'Introduction meeting completed', status: 'NOT_STARTED', verifiedBy: 'Admin' },
];
const completedCount = items.filter((i) => i.status === 'COMPLETE').length;
const totalCount = items.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
);
}
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-card rounded-xl border p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold">Onboarding Checklist</h2>
<span className="text-sm font-medium">
{completedCount}/{totalCount} complete ({progressPercent}%)
</span>
</div>
<div className="h-3 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
style={{ width: `
$
{
progressPercent
}
%
` }}
/>
</div>
{progressPercent === 100 && (
<p className="text-sm text-emerald-600 mt-3 font-medium">
🎉 All items complete! An admin will activate your account shortly.
</p>
)}
</div>
{/* Checklist items */}
<div className="bg-card rounded-xl border divide-y">
{items.map((item) => {
const statusConfig = {
COMPLETE: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
PENDING: { icon: Clock, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
NOT_STARTED: { icon: AlertCircle, color: 'text-muted-foreground', bg: 'bg-muted' },
};
const config = statusConfig[item.status];
const Icon = config.icon;
return (
<div key={item.id} className="p-4 flex items-center gap-4">
<div className={cn('p-2 rounded-full', config.bg)}>
<Icon size={18} className={config.color} />
</div>
<div className="flex-1">
<p
className={cn(
'text-sm font-medium',
item.status === 'COMPLETE' && 'line-through text-muted-foreground',
)}
>
{item.name}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Verified by: {item.verifiedBy}
{item.completedAt && `
·
Completed
$
{
new
Date
(
item
.
completedAt
).
toLocaleDateString
()}
`}
</p>
</div>
<span
className={cn(
'text-[10px] font-medium px-2 py-0.5 rounded-full',
item.status === 'COMPLETE'
? 'bg-emerald-500/10 text-emerald-600'
: item.status === 'PENDING'
? 'bg-yellow-500/10 text-yellow-600'
: 'bg-muted text-muted-foreground',
)}
>
{item.status === 'COMPLETE'
? '✅ Complete'
: item.status === 'PENDING'
? '⏳ Pending'
: '❌ Not Started'}
</span>
</div>
);
})}
</div>
{/* Info */}
<div className="bg-accent/50 rounded-xl p-4 text-sm text-muted-foreground">
<p>
<strong>Note:</strong> Some items are verified automatically by the system. Others
require your Project Leader or Admin to confirm. If you have questions, use the
messaging system to contact your admin.
</p>
</div>
</div>
);
}
\ No newline at end of file
frontend/src/components/hud/hud-expanded.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
formatEgp
}
from
'@/lib/utils'
;
import
{
formatDate
}
from
'@/lib/date'
;
import
{
TrendingUp
,
TrendingDown
,
DollarSign
,
Coins
,
AlertTriangle
}
from
'lucide-react'
;
import
Link
from
'next/link'
;
interface
HudLineItem
{
id
:
string
;
type
:
'salary'
|
'bounty'
|
'deduction'
|
'adjustment'
;
label
:
string
;
amountPiasters
:
number
;
link
?:
string
;
date
?:
string
;
}
interface
HudExpandedProps
{
actualSalaryPiasters
:
number
;
liveSalaryPiasters
:
number
;
items
:
HudLineItem
[];
onClose
:
()
=>
void
;
}
export
function
HudExpanded
({
actualSalaryPiasters
,
liveSalaryPiasters
,
items
,
onClose
,
}:
HudExpandedProps
)
{
const
sortedItems
=
[...
items
].
sort
(
(
a
,
b
)
=>
new
Date
(
a
.
date
||
0
).
getTime
()
-
new
Date
(
b
.
date
||
0
).
getTime
(),
);
return
(
<
div
className=
"absolute top-full left-0 right-0 mt-1 z-50 animate-fade-in"
>
<
div
className=
"bg-card rounded-xl border shadow-xl p-3 max-h-[60vh] overflow-y-auto"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between mb-3 pb-2 border-b"
>
<
span
className=
"text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Salary Breakdown
</
span
>
<
button
onClick=
{
onClose
}
className=
"text-xs text-muted-foreground hover:text-foreground"
>
Close
</
button
>
</
div
>
{
/* Base salary */
}
<
div
className=
"flex items-center justify-between py-1.5 text-sm"
>
<
div
className=
"flex items-center gap-2"
>
<
DollarSign
size=
{
14
}
className=
"text-muted-foreground"
/>
<
span
>
Actual Salary
</
span
>
</
div
>
<
span
className=
"font-mono font-medium"
>
{
formatEgp
(
actualSalaryPiasters
)
}
</
span
>
</
div
>
{
/* Line items */
}
{
sortedItems
.
map
((
item
)
=>
{
const
isPositive
=
item
.
amountPiasters
>
0
;
const
Icon
=
isPositive
?
TrendingUp
:
TrendingDown
;
const
color
=
isPositive
?
'text-emerald-500'
:
'text-red-500'
;
const
content
=
(
<
div
className=
"flex items-center justify-between py-1.5 text-sm hover:bg-accent/50 rounded px-1 -mx-1 transition-colors"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
{
item
.
type
===
'bounty'
&&
<
Coins
size=
{
14
}
className=
"text-amber-500 shrink-0"
/>
}
{
item
.
type
===
'deduction'
&&
(
<
AlertTriangle
size=
{
14
}
className=
"text-red-500 shrink-0"
/>
)
}
{
item
.
type
===
'adjustment'
&&
(
<
Icon
size=
{
14
}
className=
{
`${color} shrink-0`
}
/>
)
}
<
span
className=
"truncate text-muted-foreground"
>
{
item
.
label
}
</
span
>
</
div
>
<
span
className=
{
`font-mono font-medium shrink-0 ml-2 ${color}`
}
>
{
isPositive
?
'+'
:
''
}
{
formatEgp
(
item
.
amountPiasters
)
}
</
span
>
</
div
>
);
if
(
item
.
link
)
{
return
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
link
}
>
{
content
}
</
Link
>
);
}
return
<
div
key=
{
item
.
id
}
>
{
content
}
</
div
>;
})
}
{
sortedItems
.
length
===
0
&&
(
<
p
className=
"text-xs text-muted-foreground text-center py-4"
>
No bounties, deductions, or adjustments this month
</
p
>
)
}
{
/* Total */
}
<
div
className=
"flex items-center justify-between pt-2 mt-2 border-t text-sm font-bold"
>
<
span
>
= Live Salary
</
span
>
<
span
className=
"font-mono"
>
{
formatEgp
(
liveSalaryPiasters
)
}
</
span
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/hud/salary-animation.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
SalaryAnimationProps
{
value
:
number
;
prefix
?:
string
;
className
?:
string
;
duration
?:
number
;
formatFn
?:
(
n
:
number
)
=>
string
;
}
export
function
SalaryAnimation
({
value
,
prefix
=
''
,
className
,
duration
=
600
,
formatFn
,
}:
SalaryAnimationProps
)
{
const
[
displayValue
,
setDisplayValue
]
=
useState
(
value
);
const
[
isAnimating
,
setIsAnimating
]
=
useState
(
false
);
const
[
direction
,
setDirection
]
=
useState
<
'up'
|
'down'
|
null
>
(
null
);
const
prevValueRef
=
useRef
(
value
);
const
animationRef
=
useRef
<
number
|
null
>
(
null
);
useEffect
(()
=>
{
const
prevValue
=
prevValueRef
.
current
;
if
(
prevValue
===
value
)
return
;
setDirection
(
value
>
prevValue
?
'up'
:
'down'
);
setIsAnimating
(
true
);
const
startTime
=
performance
.
now
();
const
startValue
=
prevValue
;
const
diff
=
value
-
prevValue
;
const
animate
=
(
currentTime
:
number
)
=>
{
const
elapsed
=
currentTime
-
startTime
;
const
progress
=
Math
.
min
(
elapsed
/
duration
,
1
);
const
eased
=
1
-
Math
.
pow
(
1
-
progress
,
3
);
// ease-out cubic
const
current
=
Math
.
round
(
startValue
+
diff
*
eased
);
setDisplayValue
(
current
);
if
(
progress
<
1
)
{
animationRef
.
current
=
requestAnimationFrame
(
animate
);
}
else
{
setDisplayValue
(
value
);
setTimeout
(()
=>
{
setIsAnimating
(
false
);
setDirection
(
null
);
},
300
);
}
};
if
(
animationRef
.
current
)
{
cancelAnimationFrame
(
animationRef
.
current
);
}
animationRef
.
current
=
requestAnimationFrame
(
animate
);
prevValueRef
.
current
=
value
;
return
()
=>
{
if
(
animationRef
.
current
)
{
cancelAnimationFrame
(
animationRef
.
current
);
}
};
},
[
value
,
duration
]);
const
formatted
=
formatFn
?
formatFn
(
displayValue
)
:
`
${
prefix
}${(
displayValue
/
100
).
toLocaleString
(
'en-EG'
,
{
minimumFractionDigits
:
0
,
maximumFractionDigits
:
0
,
})}
`
;
return
(
<
span
className=
{
cn
(
'tabular-nums transition-colors duration-300'
,
isAnimating
&&
direction
===
'up'
&&
'text-emerald-500'
,
isAnimating
&&
direction
===
'down'
&&
'text-red-500'
,
className
,
)
}
>
{
formatted
}
</
span
>
);
}
\ No newline at end of file
frontend/src/components/kanban/board-filters.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
apiGet
}
from
'@/lib/api'
;
import
{
UserAvatar
}
from
'@/components/shared/user-avatar'
;
import
{
StatusBadge
}
from
'@/components/shared/status-badge'
;
import
{
cn
}
from
'@/lib/utils'
;
import
{
Filter
,
X
,
Search
,
Save
,
ChevronDown
}
from
'lucide-react'
;
export
interface
BoardFilters
{
assigneeIds
:
string
[];
labelIds
:
string
[];
priorities
:
string
[];
deadline
:
string
;
columnIds
:
string
[];
hasBounty
:
string
;
search
:
string
;
}
const
EMPTY_FILTERS
:
BoardFilters
=
{
assigneeIds
:
[],
labelIds
:
[],
priorities
:
[],
deadline
:
''
,
columnIds
:
[],
hasBounty
:
''
,
search
:
''
,
};
interface
BoardFiltersBarProps
{
boardId
:
string
;
filters
:
BoardFilters
;
onFiltersChange
:
(
filters
:
BoardFilters
)
=>
void
;
members
?:
any
[];
labels
?:
any
[];
columns
?:
any
[];
}
export
function
BoardFiltersBar
({
boardId
,
filters
,
onFiltersChange
,
members
=
[],
labels
=
[],
columns
=
[],
}:
BoardFiltersBarProps
)
{
const
[
showPanel
,
setShowPanel
]
=
useState
(
false
);
const
[
savedFilters
,
setSavedFilters
]
=
useState
<
any
[]
>
([]);
const
[
saveName
,
setSaveName
]
=
useState
(
''
);
const
[
showSave
,
setShowSave
]
=
useState
(
false
);
const
hasActiveFilters
=
filters
.
assigneeIds
.
length
>
0
||
filters
.
labelIds
.
length
>
0
||
filters
.
priorities
.
length
>
0
||
filters
.
deadline
!==
''
||
filters
.
columnIds
.
length
>
0
||
filters
.
hasBounty
!==
''
||
filters
.
search
!==
''
;
const
activeFilterCount
=
[
filters
.
assigneeIds
.
length
>
0
,
filters
.
labelIds
.
length
>
0
,
filters
.
priorities
.
length
>
0
,
filters
.
deadline
!==
''
,
filters
.
columnIds
.
length
>
0
,
filters
.
hasBounty
!==
''
,
filters
.
search
!==
''
,
].
filter
(
Boolean
).
length
;
useEffect
(()
=>
{
loadSavedFilters
();
},
[
boardId
]);
const
loadSavedFilters
=
async
()
=>
{
try
{
const
res
=
await
apiGet
(
`/boards/
${
boardId
}
/saved-filters`
);
setSavedFilters
(
res
.
data
||
[]);
}
catch
{
setSavedFilters
([]);
}
};
const
handleClearAll
=
()
=>
{
onFiltersChange
(
EMPTY_FILTERS
);
};
const
handleSaveFilter
=
async
()
=>
{
if
(
!
saveName
.
trim
())
return
;
try
{
await
apiGet
(
`/boards/
${
boardId
}
/saved-filters`
);
// placeholder - would be apiPost
setSaveName
(
''
);
setShowSave
(
false
);
loadSavedFilters
();
}
catch
{
/* ignore */
}
};
const
toggleArrayFilter
=
(
key
:
'assigneeIds'
|
'labelIds'
|
'priorities'
|
'columnIds'
,
value
:
string
,
)
=>
{
const
current
=
filters
[
key
];
const
updated
=
current
.
includes
(
value
)
?
current
.
filter
((
v
)
=>
v
!==
value
)
:
[...
current
,
value
];
onFiltersChange
({
...
filters
,
[
key
]:
updated
});
};
return
(
<
div
className=
"space-y-2"
>
{
/* Filter bar */
}
<
div
className=
"flex items-center gap-2 flex-wrap"
>
{
/* Search */
}
<
div
className=
"relative"
>
<
Search
size=
{
14
}
className=
"absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<
input
type=
"text"
value=
{
filters
.
search
}
onChange=
{
(
e
)
=>
onFiltersChange
({
...
filters
,
search
:
e
.
target
.
value
})
}
placeholder=
"Search cards..."
className=
"pl-8 pr-3 py-1.5 rounded-lg border bg-background text-xs w-48 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
{
/* Filter toggle button */
}
<
button
onClick=
{
()
=>
setShowPanel
(
!
showPanel
)
}
className=
{
cn
(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs transition-colors'
,
showPanel
||
hasActiveFilters
?
'bg-primary text-primary-foreground'
:
'hover:bg-accent'
,
)
}
>
<
Filter
size=
{
12
}
/>
Filter
{
activeFilterCount
>
0
&&
(
<
span
className=
"min-w-[16px] h-4 bg-primary-foreground/20 rounded-full text-[10px] flex items-center justify-center"
>
{
activeFilterCount
}
</
span
>
)
}
</
button
>
{
/* Quick filter chips */
}
<
button
onClick=
{
()
=>
onFiltersChange
({
...
filters
,
deadline
:
filters
.
deadline
===
'overdue'
?
''
:
'overdue'
,
})
}
className=
{
cn
(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors'
,
filters
.
deadline
===
'overdue'
?
'bg-red-500/10 border-red-500/30 text-red-600'
:
'hover:bg-accent'
,
)
}
>
🔴 Overdue
</
button
>
<
button
onClick=
{
()
=>
onFiltersChange
({
...
filters
,
deadline
:
filters
.
deadline
===
'today'
?
''
:
'today'
,
})
}
className=
{
cn
(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors'
,
filters
.
deadline
===
'today'
?
'bg-yellow-500/10 border-yellow-500/30 text-yellow-600'
:
'hover:bg-accent'
,
)
}
>
⏰ Due Today
</
button
>
<
button
onClick=
{
()
=>
onFiltersChange
({
...
filters
,
hasBounty
:
filters
.
hasBounty
===
'yes'
?
''
:
'yes'
,
})
}
className=
{
cn
(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors'
,
filters
.
hasBounty
===
'yes'
?
'bg-amber-500/10 border-amber-500/30 text-amber-600'
:
'hover:bg-accent'
,
)
}
>
💰 Has Bounty
</
button
>
{
/* Saved filters */
}
{
savedFilters
.
length
>
0
&&
(
<
div
className=
"relative group"
>
<
button
className=
"flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs hover:bg-accent"
>
<
Save
size=
{
12
}
/>
Saved
<
ChevronDown
size=
{
10
}
/>
</
button
>
<
div
className=
"absolute top-full left-0 mt-1 hidden group-hover:block z-20 bg-card border rounded-lg shadow-lg p-1 min-w-[150px]"
>
{
savedFilters
.
map
((
sf
)
=>
(
<
button
key=
{
sf
.
id
}
onClick=
{
()
=>
onFiltersChange
(
sf
.
filters
)
}
className=
"w-full text-left px-3 py-1.5 text-xs rounded hover:bg-accent"
>
{
sf
.
name
}
</
button
>
))
}
</
div
>
</
div
>
)
}
{
/* Clear all */
}
{
hasActiveFilters
&&
(
<
button
onClick=
{
handleClearAll
}
className=
"flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<
X
size=
{
12
}
/>
Clear all
</
button
>
)
}
</
div
>
{
/* Active filter chips */
}
{
hasActiveFilters
&&
(
<
div
className=
"flex gap-1 flex-wrap"
>
{
filters
.
assigneeIds
.
map
((
id
)
=>
{
const
member
=
members
.
find
((
m
)
=>
m
.
id
===
id
||
m
.
userId
===
id
);
return
(
<
span
key=
{
`a-${id}`
}
className=
"flex items-center gap-1 px-2 py-0.5 bg-blue-500/10 text-blue-600 rounded-full text-[10px]"
>
👤
{
member
?.
firstName
||
'User'
}
<
button
onClick=
{
()
=>
toggleArrayFilter
(
'assigneeIds'
,
id
)
}
>
<
X
size=
{
10
}
/>
</
button
>
</
span
>
);
})
}
{
filters
.
labelIds
.
map
((
id
)
=>
{
const
label
=
labels
.
find
((
l
)
=>
l
.
id
===
id
);
return
(
<
span
key=
{
`l-${id}`
}
className=
"flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px]"
style=
{
{
backgroundColor
:
(
label
?.
color
||
'#6b7280'
)
+
'20'
,
color
:
label
?.
color
||
'#6b7280'
,
}
}
>
🏷️
{
label
?.
name
||
label
?.
text
||
'Label'
}
<
button
onClick=
{
()
=>
toggleArrayFilter
(
'labelIds'
,
id
)
}
>
<
X
size=
{
10
}
/>
</
button
>
</
span
>
);
})
}
{
filters
.
priorities
.
map
((
p
)
=>
(
<
span
key=
{
`p-${p}`
}
className=
"flex items-center gap-1 px-2 py-0.5 bg-muted rounded-full text-[10px]"
>
<
StatusBadge
status=
{
p
}
/>
<
button
onClick=
{
()
=>
toggleArrayFilter
(
'priorities'
,
p
)
}
>
<
X
size=
{
10
}
/>
</
button
>
</
span
>
))
}
</
div
>
)
}
{
/* Expanded filter panel */
}
{
showPanel
&&
(
<
div
className=
"bg-card rounded-xl border p-4 space-y-4 animate-fade-in"
>
<
div
className=
"grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{
/* Priority filter */
}
<
div
className=
"space-y-1.5"
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
Priority
</
label
>
<
div
className=
"space-y-1"
>
{
[
'CRITICAL'
,
'HIGH'
,
'MEDIUM'
,
'LOW'
,
'NONE'
].
map
((
p
)
=>
(
<
label
key=
{
p
}
className=
"flex items-center gap-2 cursor-pointer text-xs"
>
<
input
type=
"checkbox"
checked=
{
filters
.
priorities
.
includes
(
p
)
}
onChange=
{
()
=>
toggleArrayFilter
(
'priorities'
,
p
)
}
className=
"rounded"
/>
<
StatusBadge
status=
{
p
}
/>
</
label
>
))
}
</
div
>
</
div
>
{
/* Assignee filter */
}
{
members
.
length
>
0
&&
(
<
div
className=
"space-y-1.5"
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
Assignee
</
label
>
<
div
className=
"space-y-1 max-h-32 overflow-y-auto"
>
{
members
.
map
((
m
)
=>
{
const
id
=
m
.
userId
||
m
.
id
;
return
(
<
label
key=
{
id
}
className=
"flex items-center gap-2 cursor-pointer text-xs"
>
<
input
type=
"checkbox"
checked=
{
filters
.
assigneeIds
.
includes
(
id
)
}
onChange=
{
()
=>
toggleArrayFilter
(
'assigneeIds'
,
id
)
}
className=
"rounded"
/>
<
UserAvatar
firstName=
{
m
.
firstName
||
m
.
user
?.
firstName
||
'?'
}
lastName=
{
m
.
lastName
||
m
.
user
?.
lastName
||
'?'
}
size=
"xs"
/>
<
span
>
{
m
.
firstName
||
m
.
user
?.
firstName
}
{
m
.
lastName
||
m
.
user
?.
lastName
}
</
span
>
</
label
>
);
})
}
</
div
>
</
div
>
)
}
{
/* Label filter */
}
{
labels
.
length
>
0
&&
(
<
div
className=
"space-y-1.5"
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
Labels
</
label
>
<
div
className=
"space-y-1 max-h-32 overflow-y-auto"
>
{
labels
.
map
((
l
)
=>
(
<
label
key=
{
l
.
id
}
className=
"flex items-center gap-2 cursor-pointer text-xs"
>
<
input
type=
"checkbox"
checked=
{
filters
.
labelIds
.
includes
(
l
.
id
)
}
onChange=
{
()
=>
toggleArrayFilter
(
'labelIds'
,
l
.
id
)
}
className=
"rounded"
/>
<
span
className=
"w-3 h-3 rounded-sm shrink-0"
style=
{
{
backgroundColor
:
l
.
color
}
}
/>
<
span
>
{
l
.
name
||
l
.
text
}
</
span
>
</
label
>
))
}
</
div
>
</
div
>
)
}
{
/* Deadline filter */
}
<
div
className=
"space-y-1.5"
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
Deadline
</
label
>
<
select
value=
{
filters
.
deadline
}
onChange=
{
(
e
)
=>
onFiltersChange
({
...
filters
,
deadline
:
e
.
target
.
value
})
}
className=
"w-full px-2 py-1.5 rounded-lg border bg-background text-xs"
>
<
option
value=
""
>
Any
</
option
>
<
option
value=
"overdue"
>
Overdue
</
option
>
<
option
value=
"today"
>
Due Today
</
option
>
<
option
value=
"this-week"
>
Due This Week
</
option
>
<
option
value=
"this-month"
>
Due This Month
</
option
>
<
option
value=
"no-deadline"
>
No Deadline
</
option
>
</
select
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
frontend/src/components/kanban/column-header.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useState
}
from
'react'
;
import
{
Plus
,
MoreHorizontal
,
GripVertical
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
interface
ColumnHeaderProps
{
column
:
{
id
:
string
;
name
:
string
;
type
:
string
;
wipLimit
?:
number
;
icon
?:
string
;
};
cardCount
:
number
;
onAddCard
?:
()
=>
void
;
canAddCard
:
boolean
;
}
const
COLUMN_ICONS
:
Record
<
string
,
string
>
=
{
BACKLOG
:
'📋'
,
TODO
:
'📌'
,
DOING
:
'🔨'
,
FROZEN
:
'🧊'
,
IN_REVIEW
:
'🔍'
,
DONE
:
'✅'
,
CUSTOM
:
'📁'
,
};
export
function
ColumnHeader
({
column
,
cardCount
,
onAddCard
,
canAddCard
}:
ColumnHeaderProps
)
{
const
icon
=
column
.
icon
||
COLUMN_ICONS
[
column
.
type
]
||
'📁'
;
const
isAtWipLimit
=
column
.
wipLimit
&&
cardCount
>=
column
.
wipLimit
;
return
(
<
div
className=
"flex items-center justify-between px-2 py-2 mb-2"
>
<
div
className=
"flex items-center gap-2 min-w-0"
>
<
span
className=
"text-base"
>
{
icon
}
</
span
>
<
h3
className=
"text-sm font-semibold truncate"
>
{
column
.
name
}
</
h3
>
<
span
className=
{
cn
(
'text-[10px] font-mono px-1.5 py-0.5 rounded-full min-w-[20px] text-center'
,
isAtWipLimit
?
'bg-red-500/10 text-red-500 font-bold'
:
'bg-muted text-muted-foreground'
,
)
}
>
{
cardCount
}
{
column
.
wipLimit
?
`/${column.wipLimit}`
:
''
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-0.5"
>
{
canAddCard
&&
(
<
button
onClick=
{
onAddCard
}
disabled=
{
!!
isAtWipLimit
}
className=
{
cn
(
'p-1 rounded-md transition-colors'
,
isAtWipLimit
?
'opacity-30 cursor-not-allowed'
:
'hover:bg-accent text-muted-foreground hover:text-foreground'
,
)
}
title=
{
isAtWipLimit
?
'WIP limit reached'
:
'Add card'
}
>
<
Plus
size=
{
14
}
/>
</
button
>
)
}
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/kanban/create-card-dialog.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
apiGet
,
apiPost
}
from
'@/lib/api'
;
import
{
useAuthStore
}
from
'@/stores/auth.store'
;
import
{
toast
}
from
'sonner'
;
import
{
Plus
,
Loader2
,
X
,
Coins
,
Clock
,
Flag
}
from
'lucide-react'
;
interface
CreateCardDialogProps
{
boardId
:
string
;
columnId
?:
string
;
onCreated
:
()
=>
void
;
onClose
:
()
=>
void
;
open
:
boolean
;
}
export
function
CreateCardDialog
({
boardId
,
columnId
,
onCreated
,
onClose
,
open
,
}:
CreateCardDialogProps
)
{
const
user
=
useAuthStore
((
s
)
=>
s
.
user
);
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
);
const
[
columns
,
setColumns
]
=
useState
<
any
[]
>
([]);
const
[
labels
,
setLabels
]
=
useState
<
any
[]
>
([]);
const
[
members
,
setMembers
]
=
useState
<
any
[]
>
([]);
const
[
templates
,
setTemplates
]
=
useState
<
any
[]
>
([]);
const
[
showAdvanced
,
setShowAdvanced
]
=
useState
(
false
);
const
[
form
,
setForm
]
=
useState
({
title
:
''
,
description
:
''
,
columnId
:
columnId
||
''
,
priority
:
'NONE'
,
assigneeIds
:
[]
as
string
[],
labelIds
:
[]
as
string
[],
dueDate
:
''
,
estimatedHours
:
0
,
bountyPiasters
:
0
,
});
useEffect
(()
=>
{
if
(
!
open
)
return
;
loadBoardData
();
},
[
open
,
boardId
]);
useEffect
(()
=>
{
if
(
columnId
)
{
setForm
((
prev
)
=>
({
...
prev
,
columnId
}));
}
},
[
columnId
]);
const
loadBoardData
=
async
()
=>
{
try
{
const
[
boardRes
,
labelRes
]
=
await
Promise
.
all
([
apiGet
(
`/boards/
${
boardId
}
`
),
apiGet
(
'/labels'
,
{
boardId
,
limit
:
100
}),
]);
const
board
=
boardRes
.
data
;
setColumns
(
board
.
columns
||
[]);
setMembers
(
board
.
members
?.
map
((
m
:
any
)
=>
m
.
user
||
m
)
||
[]);
setLabels
(
labelRes
.
data
||
[]);
if
(
!
form
.
columnId
&&
board
.
columns
?.
length
>
0
)
{
const
backlog
=
board
.
columns
.
find
((
c
:
any
)
=>
c
.
type
===
'BACKLOG'
);
setForm
((
prev
)
=>
({
...
prev
,
columnId
:
backlog
?.
id
||
board
.
columns
[
0
].
id
}));
}
try
{
const
tplRes
=
await
apiGet
(
'/cards/templates'
,
{
boardId
,
limit
:
50
});
setTemplates
(
tplRes
.
data
||
[]);
}
catch
{
setTemplates
([]);
}
}
catch
(
err
)
{
console
.
error
(
'Failed to load board data:'
,
err
);
}
};
const
handleTemplateSelect
=
(
template
:
any
)
=>
{
setForm
((
prev
)
=>
({
...
prev
,
title
:
template
.
title
||
prev
.
title
,
description
:
template
.
description
||
prev
.
description
,
priority
:
template
.
priority
||
prev
.
priority
,
estimatedHours
:
template
.
estimatedHours
||
prev
.
estimatedHours
,
labelIds
:
template
.
labelIds
||
prev
.
labelIds
,
}));
toast
.
success
(
`Template "
${
template
.
name
}
" applied`
);
};
const
handleSubmit
=
async
()
=>
{
if
(
!
form
.
title
.
trim
())
{
toast
.
error
(
'Title is required'
);
return
;
}
if
(
!
form
.
columnId
)
{
toast
.
error
(
'Select a column'
);
return
;
}
setIsSubmitting
(
true
);
try
{
const
payload
:
any
=
{
title
:
form
.
title
.
trim
(),
columnId
:
form
.
columnId
,
boardId
,
};
if
(
form
.
description
.
trim
())
payload
.
description
=
form
.
description
.
trim
();
if
(
form
.
priority
!==
'NONE'
)
payload
.
priority
=
form
.
priority
;
if
(
form
.
assigneeIds
.
length
>
0
)
payload
.
assigneeIds
=
form
.
assigneeIds
;
if
(
form
.
labelIds
.
length
>
0
)
payload
.
labelIds
=
form
.
labelIds
;
if
(
form
.
dueDate
)
payload
.
dueDate
=
new
Date
(
form
.
dueDate
).
toISOString
();
if
(
form
.
estimatedHours
>
0
)
payload
.
estimatedHours
=
form
.
estimatedHours
;
if
(
form
.
bountyPiasters
>
0
)
payload
.
bountyPiasters
=
form
.
bountyPiasters
;
await
apiPost
(
'/cards'
,
payload
);
toast
.
success
(
'Card created'
);
resetForm
();
onCreated
();
onClose
();
}
catch
(
err
:
any
)
{
toast
.
error
(
err
.
message
||
'Failed to create card'
);
}
finally
{
setIsSubmitting
(
false
);
}
};
const
resetForm
=
()
=>
{
setForm
({
title
:
''
,
description
:
''
,
columnId
:
columnId
||
''
,
priority
:
'NONE'
,
assigneeIds
:
[],
labelIds
:
[],
dueDate
:
''
,
estimatedHours
:
0
,
bountyPiasters
:
0
,
});
setShowAdvanced
(
false
);
};
const
toggleAssignee
=
(
userId
:
string
)
=>
{
setForm
((
prev
)
=>
({
...
prev
,
assigneeIds
:
prev
.
assigneeIds
.
includes
(
userId
)
?
prev
.
assigneeIds
.
filter
((
id
)
=>
id
!==
userId
)
:
[...
prev
.
assigneeIds
,
userId
],
}));
};
const
toggleLabel
=
(
labelId
:
string
)
=>
{
setForm
((
prev
)
=>
({
...
prev
,
labelIds
:
prev
.
labelIds
.
includes
(
labelId
)
?
prev
.
labelIds
.
filter
((
id
)
=>
id
!==
labelId
)
:
[...
prev
.
labelIds
,
labelId
],
}));
};
const
isAdmin
=
user
?.
role
===
'SUPER_ADMIN'
||
user
?.
role
===
'ADMIN'
;
const
canSetBounty
=
isAdmin
;
const
canAssign
=
isAdmin
||
user
?.
role
===
'TEAM_LEAD'
;
if
(
!
open
)
return
null
;
return
(
<
div
className=
"fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-[10vh] p-4 overflow-y-auto"
onClick=
{
onClose
}
>
<
div
className=
"bg-card rounded-xl border shadow-2xl w-full max-w-2xl animate-fade-in"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between p-4 border-b"
>
<
h2
className=
"text-lg font-semibold"
>
Create Card
</
h2
>
<
button
onClick=
{
onClose
}
className=
"p-1 rounded-md hover:bg-accent"
>
<
X
size=
{
18
}
/>
</
button
>
</
div
>
<
div
className=
"p-4 space-y-4"
>
{
/* Templates */
}
{
templates
.
length
>
0
&&
(
<
div
>
<
label
className=
"text-xs font-medium text-muted-foreground"
>
From Template
</
label
>
<
div
className=
"flex gap-1 mt-1 flex-wrap"
>
{
templates
.
map
((
tpl
)
=>
(
<
button
key=
{
tpl
.
id
}
onClick=
{
()
=>
handleTemplateSelect
(
tpl
)
}
className=
"px-2 py-1 text-xs rounded-md border hover:bg-accent transition-colors"
>
{
tpl
.
name
}
</
button
>
))
}
</
div
>
</
div
>
)
}
{
/* Title */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Title *
</
label
>
<
input
type=
"text"
value=
{
form
.
title
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
title
:
e
.
target
.
value
})
}
placeholder=
"What needs to be done?"
autoFocus
maxLength=
{
200
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
&&
form
.
title
.
trim
())
{
e
.
preventDefault
();
handleSubmit
();
}
}
}
/>
</
div
>
{
/* Column selector */
}
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Column *
</
label
>
<
select
value=
{
form
.
columnId
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
columnId
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{
columns
.
map
((
col
)
=>
(
<
option
key=
{
col
.
id
}
value=
{
col
.
id
}
>
{
col
.
name
}
</
option
>
))
}
</
select
>
</
div
>
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Priority
</
label
>
<
select
value=
{
form
.
priority
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
priority
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<
option
value=
"NONE"
>
⚪ None
</
option
>
<
option
value=
"LOW"
>
🟢 Low
</
option
>
<
option
value=
"MEDIUM"
>
🟡 Medium
</
option
>
<
option
value=
"HIGH"
>
🟠 High
</
option
>
<
option
value=
"CRITICAL"
>
🔴 Critical
</
option
>
</
select
>
</
div
>
</
div
>
{
/* Description */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Description
</
label
>
<
textarea
value=
{
form
.
description
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
description
:
e
.
target
.
value
})
}
placeholder=
"Add details, acceptance criteria, specs..."
rows=
{
3
}
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"
/>
</
div
>
{
/* Labels */
}
{
labels
.
length
>
0
&&
(
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Labels
</
label
>
<
div
className=
"flex gap-1 flex-wrap"
>
{
labels
.
map
((
label
)
=>
(
<
button
key=
{
label
.
id
}
onClick=
{
()
=>
toggleLabel
(
label
.
id
)
}
className=
"px-2 py-1 rounded-md text-xs font-medium border transition-colors"
style=
{
{
backgroundColor
:
form
.
labelIds
.
includes
(
label
.
id
)
?
label
.
color
+
'30'
:
undefined
,
borderColor
:
form
.
labelIds
.
includes
(
label
.
id
)
?
label
.
color
:
undefined
,
color
:
form
.
labelIds
.
includes
(
label
.
id
)
?
label
.
color
:
undefined
,
}
}
>
{
label
.
name
||
label
.
text
}
</
button
>
))
}
</
div
>
</
div
>
)
}
{
/* Advanced toggle */
}
<
button
onClick=
{
()
=>
setShowAdvanced
(
!
showAdvanced
)
}
className=
"text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{
showAdvanced
?
'▾ Hide advanced options'
:
'▸ Show advanced options'
}
</
button
>
{
showAdvanced
&&
(
<
div
className=
"space-y-4 border-t pt-4"
>
{
/* Assignees */
}
{
canAssign
&&
members
.
length
>
0
&&
(
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium"
>
Assignees
</
label
>
<
div
className=
"flex gap-1 flex-wrap"
>
{
members
.
map
((
m
)
=>
(
<
button
key=
{
m
.
id
}
onClick=
{
()
=>
toggleAssignee
(
m
.
id
)
}
className=
{
`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border transition-colors ${
form.assigneeIds.includes(m.id)
? 'bg-primary/10 border-primary/30 text-primary font-medium'
: 'hover:bg-accent/50'
}`
}
>
<
div
className=
"w-4 h-4 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold"
>
{
m
.
firstName
?.[
0
]
}
{
m
.
lastName
?.[
0
]
}
</
div
>
{
m
.
firstName
}
{
m
.
lastName
}
</
button
>
))
}
</
div
>
</
div
>
)
}
<
div
className=
"grid gap-4 sm:grid-cols-3"
>
{
/* Deadline */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium flex items-center gap-1"
>
<
Clock
size=
{
12
}
/>
Deadline
</
label
>
<
input
type=
"datetime-local"
value=
{
form
.
dueDate
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
dueDate
:
e
.
target
.
value
})
}
className=
"w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</
div
>
{
/* Estimated Hours */
}
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium flex items-center gap-1"
>
<
Flag
size=
{
12
}
/>
Est. Hours
</
label
>
<
input
type=
"number"
value=
{
form
.
estimatedHours
||
''
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
estimatedHours
:
parseFloat
(
e
.
target
.
value
)
||
0
})
}
min=
{
0
}
step=
{
0.5
}
placeholder=
"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
>
{
/* Bounty */
}
{
canSetBounty
&&
(
<
div
className=
"space-y-1"
>
<
label
className=
"text-sm font-medium flex items-center gap-1"
>
<
Coins
size=
{
12
}
/>
Bounty (piasters)
</
label
>
<
input
type=
"number"
value=
{
form
.
bountyPiasters
||
''
}
onChange=
{
(
e
)
=>
setForm
({
...
form
,
bountyPiasters
:
parseInt
(
e
.
target
.
value
)
||
0
})
}
min=
{
0
}
step=
{
100
}
placeholder=
"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
.
bountyPiasters
>
0
&&
(
<
p
className=
"text-xs text-amber-500"
>
💰
{
(
form
.
bountyPiasters
/
100
).
toLocaleString
()
}
EGP
</
p
>
)
}
</
div
>
)
}
</
div
>
</
div
>
)
}
</
div
>
{
/* Footer */
}
<
div
className=
"flex items-center justify-between p-4 border-t bg-muted/30"
>
<
p
className=
"text-xs text-muted-foreground"
>
Press
<
kbd
className=
"px-1 py-0.5 bg-muted rounded text-[10px]"
>
Enter
</
kbd
>
to create
quickly
</
p
>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
onClose
}
className=
"px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</
button
>
<
button
onClick=
{
handleSubmit
}
disabled=
{
isSubmitting
||
!
form
.
title
.
trim
()
}
className=
"flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{
isSubmitting
?
(
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
)
:
(
<
Plus
size=
{
14
}
/>
)
}
Create Card
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/shared/keyboard-shortcuts-help.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
X
,
Keyboard
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
const
SHORTCUTS
=
[
{
category
:
'Navigation'
,
shortcuts
:
[
{
keys
:
[
'Ctrl'
,
'K'
],
description
:
'Open command palette / search'
},
{
keys
:
[
'G'
,
'B'
],
description
:
'Go to Boards'
},
{
keys
:
[
'G'
,
'T'
],
description
:
'Go to My Tasks'
},
{
keys
:
[
'G'
,
'R'
],
description
:
'Go to Reports'
},
{
keys
:
[
'G'
,
'M'
],
description
:
'Go to Messages'
},
{
keys
:
[
'G'
,
'S'
],
description
:
'Go to Salary'
},
{
keys
:
[
'G'
,
'P'
],
description
:
'Go to Profile'
},
{
keys
:
[
'G'
,
'N'
],
description
:
'Go to Notifications'
},
]},
{
category
:
'Board'
,
shortcuts
:
[
{
keys
:
[
'N'
],
description
:
'New card (when on board)'
},
{
keys
:
[
'F'
],
description
:
'Toggle filters'
},
{
keys
:
[
'1-6'
],
description
:
'Switch to column view'
},
{
keys
:
[
'Esc'
],
description
:
'Close card detail / dialog'
},
]},
{
category
:
'General'
,
shortcuts
:
[
{
keys
:
[
'?'
],
description
:
'Show keyboard shortcuts'
},
{
keys
:
[
'Ctrl'
,
'/'
],
description
:
'Toggle dark mode'
},
]},
];
export
function
KeyboardShortcutsHelp
()
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
useEffect
(()
=>
{
const
handler
=
(
e
:
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'?'
&&
!
e
.
ctrlKey
&&
!
e
.
metaKey
)
{
const
target
=
e
.
target
as
HTMLElement
;
if
(
target
.
tagName
===
'INPUT'
||
target
.
tagName
===
'TEXTAREA'
||
target
.
isContentEditable
)
return
;
e
.
preventDefault
();
setIsOpen
((
prev
)
=>
!
prev
);
}
if
(
e
.
key
===
'Escape'
&&
isOpen
)
{
setIsOpen
(
false
);
}
};
window
.
addEventListener
(
'keydown'
,
handler
);
return
()
=>
window
.
removeEventListener
(
'keydown'
,
handler
);
},
[
isOpen
]);
if
(
!
isOpen
)
return
null
;
return
(
<
div
className=
"fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4"
onClick=
{
()
=>
setIsOpen
(
false
)
}
>
<
div
className=
"bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto animate-fade-in"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10"
>
<
div
className=
"flex items-center gap-2"
>
<
Keyboard
size=
{
18
}
/>
<
h2
className=
"text-lg font-semibold"
>
Keyboard Shortcuts
</
h2
>
</
div
>
<
button
onClick=
{
()
=>
setIsOpen
(
false
)
}
className=
"p-1 rounded-md hover:bg-accent"
>
<
X
size=
{
18
}
/>
</
button
>
</
div
>
<
div
className=
"p-4 space-y-6"
>
{
SHORTCUTS
.
map
((
group
)
=>
(
<
div
key=
{
group
.
category
}
>
<
h3
className=
"text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2"
>
{
group
.
category
}
</
h3
>
<
div
className=
"space-y-1.5"
>
{
group
.
shortcuts
.
map
((
shortcut
,
i
)
=>
(
<
div
key=
{
i
}
className=
"flex items-center justify-between py-1"
>
<
span
className=
"text-sm text-muted-foreground"
>
{
shortcut
.
description
}
</
span
>
<
div
className=
"flex items-center gap-1"
>
{
shortcut
.
keys
.
map
((
key
,
j
)
=>
(
<
span
key=
{
j
}
>
{
j
>
0
&&
<
span
className=
"text-[10px] text-muted-foreground mx-0.5"
>
+
</
span
>
}
<
kbd
className=
"px-2 py-0.5 bg-muted rounded text-xs font-mono font-medium border border-border/50"
>
{
key
}
</
kbd
>
</
span
>
))
}
</
div
>
</
div
>
))
}
</
div
>
</
div
>
))
}
</
div
>
<
div
className=
"p-4 border-t text-center"
>
<
p
className=
"text-xs text-muted-foreground"
>
Press
<
kbd
className=
"px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono"
>
?
</
kbd
>
to toggle this panel
</
p
>
</
div
>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/components/shared/offline-banner.tsx
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useOnlineStatus
}
from
'@/hooks/use-online-status'
;
import
{
WifiOff
,
Loader2
}
from
'lucide-react'
;
import
{
cn
}
from
'@/lib/utils'
;
export
function
OfflineBanner
()
{
const
isOnline
=
useOnlineStatus
();
if
(
isOnline
)
return
null
;
return
(
<
div
className=
"fixed bottom-4 left-1/2 -translate-x-1/2 z-[200] animate-slide-in-right"
>
<
div
className=
"flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-2.5 rounded-xl shadow-lg"
>
<
WifiOff
size=
{
16
}
/>
<
span
className=
"text-sm font-medium"
>
You're offline — changes will sync when reconnected
</
span
>
<
Loader2
size=
{
14
}
className=
"animate-spin ml-1"
/>
</
div
>
</
div
>
);
}
\ No newline at end of file
frontend/src/hooks/use-navigation-shortcuts.ts
0 → 100644
View file @
0a82b230
'use client'
;
import
{
useEffect
,
useRef
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
export
function
useNavigationShortcuts
()
{
const
router
=
useRouter
();
const
pendingKeyRef
=
useRef
<
string
|
null
>
(
null
);
const
timeoutRef
=
useRef
<
NodeJS
.
Timeout
|
null
>
(
null
);
useEffect
(()
=>
{
const
handler
=
(
e
:
KeyboardEvent
)
=>
{
const
target
=
e
.
target
as
HTMLElement
;
if
(
target
.
tagName
===
'INPUT'
||
target
.
tagName
===
'TEXTAREA'
||
target
.
tagName
===
'SELECT'
||
target
.
isContentEditable
)
{
return
;
}
// Ctrl+K for search
if
((
e
.
ctrlKey
||
e
.
metaKey
)
&&
e
.
key
===
'k'
)
{
e
.
preventDefault
();
document
.
dispatchEvent
(
new
CustomEvent
(
'open-command-palette'
));
return
;
}
// Ctrl+/ for dark mode toggle
if
((
e
.
ctrlKey
||
e
.
metaKey
)
&&
e
.
key
===
'/'
)
{
e
.
preventDefault
();
document
.
dispatchEvent
(
new
CustomEvent
(
'toggle-theme'
));
return
;
}
// Two-key navigation: G then letter
if
(
pendingKeyRef
.
current
===
'g'
)
{
pendingKeyRef
.
current
=
null
;
if
(
timeoutRef
.
current
)
clearTimeout
(
timeoutRef
.
current
);
switch
(
e
.
key
.
toLowerCase
())
{
case
'b'
:
e
.
preventDefault
();
router
.
push
(
'/boards'
);
break
;
case
't'
:
e
.
preventDefault
();
router
.
push
(
'/my-tasks'
);
break
;
case
'r'
:
e
.
preventDefault
();
router
.
push
(
'/reports'
);
break
;
case
'm'
:
e
.
preventDefault
();
router
.
push
(
'/messages'
);
break
;
case
's'
:
e
.
preventDefault
();
router
.
push
(
'/salary'
);
break
;
case
'p'
:
e
.
preventDefault
();
router
.
push
(
'/profile'
);
break
;
case
'n'
:
e
.
preventDefault
();
router
.
push
(
'/notifications'
);
break
;
case
'd'
:
e
.
preventDefault
();
router
.
push
(
'/'
);
break
;
}
return
;
}
if
(
e
.
key
===
'g'
&&
!
e
.
ctrlKey
&&
!
e
.
metaKey
&&
!
e
.
altKey
)
{
pendingKeyRef
.
current
=
'g'
;
if
(
timeoutRef
.
current
)
clearTimeout
(
timeoutRef
.
current
);
timeoutRef
.
current
=
setTimeout
(()
=>
{
pendingKeyRef
.
current
=
null
;
},
500
);
}
};
window
.
addEventListener
(
'keydown'
,
handler
);
return
()
=>
{
window
.
removeEventListener
(
'keydown'
,
handler
);
if
(
timeoutRef
.
current
)
clearTimeout
(
timeoutRef
.
current
);
};
},
[
router
]);
}
\ No newline at end of file
frontend/src/hooks/use-online-status.ts
View file @
0a82b230
'use client'
;
import
{
useState
,
useEffect
,
useCallback
,
useRef
}
from
'react'
;
import
{
toast
}
from
'sonner'
;
import
{
useState
,
useEffect
}
from
'react'
;
interface
QueuedAction
{
id
:
string
;
type
:
string
;
url
:
string
;
method
:
string
;
body
?:
any
;
timestamp
:
number
;
}
const
QUEUE_KEY
=
'thegrind_offline_queue'
;
function
getQueue
():
QueuedAction
[]
{
if
(
typeof
window
===
'undefined'
)
return
[];
try
{
const
raw
=
localStorage
.
getItem
(
QUEUE_KEY
);
return
raw
?
JSON
.
parse
(
raw
)
:
[];
}
catch
{
return
[];
}
}
function
saveQueue
(
queue
:
QueuedAction
[]):
void
{
if
(
typeof
window
===
'undefined'
)
return
;
localStorage
.
setItem
(
QUEUE_KEY
,
JSON
.
stringify
(
queue
));
}
export
function
useOnlineStatus
()
{
export
function
useOnlineStatus
():
boolean
{
const
[
isOnline
,
setIsOnline
]
=
useState
(
true
);
const
[
queueLength
,
setQueueLength
]
=
useState
(
0
);
const
isSyncing
=
useRef
(
false
);
useEffect
(()
=>
{
if
(
typeof
window
===
'undefined'
)
return
;
setIsOnline
(
navigator
.
onLine
);
setQueueLength
(
getQueue
().
length
);
const
handleOnline
=
()
=>
{
setIsOnline
(
true
);
toast
.
success
(
'Back online! Syncing queued actions...'
);
syncQueue
();
};
const
handleOffline
=
()
=>
{
setIsOnline
(
false
);
toast
.
warning
(
'You are offline. Changes will be queued and synced when reconnected.'
);
};
const
handleOnline
=
()
=>
setIsOnline
(
true
);
const
handleOffline
=
()
=>
setIsOnline
(
false
);
window
.
addEventListener
(
'online'
,
handleOnline
);
window
.
addEventListener
(
'offline'
,
handleOffline
);
...
...
@@ -60,72 +22,5 @@ export function useOnlineStatus() {
};
},
[]);
const
enqueue
=
useCallback
((
action
:
Omit
<
QueuedAction
,
'id'
|
'timestamp'
>
)
=>
{
const
queue
=
getQueue
();
const
newAction
:
QueuedAction
=
{
...
action
,
id
:
`
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
9
)}
`
,
timestamp
:
Date
.
now
(),
};
queue
.
push
(
newAction
);
saveQueue
(
queue
);
setQueueLength
(
queue
.
length
);
return
newAction
.
id
;
},
[]);
const
syncQueue
=
useCallback
(
async
()
=>
{
if
(
isSyncing
.
current
)
return
;
isSyncing
.
current
=
true
;
const
queue
=
getQueue
();
if
(
queue
.
length
===
0
)
{
isSyncing
.
current
=
false
;
return
;
}
const
failed
:
QueuedAction
[]
=
[];
let
successCount
=
0
;
for
(
const
action
of
queue
)
{
try
{
const
token
=
typeof
window
!==
'undefined'
?
localStorage
.
getItem
(
'accessToken'
)
:
null
;
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
token
)
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
const
response
=
await
fetch
(
action
.
url
,
{
method
:
action
.
method
,
headers
,
body
:
action
.
body
?
JSON
.
stringify
(
action
.
body
)
:
undefined
,
});
if
(
response
.
ok
)
{
successCount
++
;
}
else
if
(
response
.
status
>=
500
)
{
failed
.
push
(
action
);
}
// 4xx errors are dropped (client error, no point retrying)
}
catch
{
failed
.
push
(
action
);
}
}
saveQueue
(
failed
);
setQueueLength
(
failed
.
length
);
if
(
successCount
>
0
)
{
toast
.
success
(
`Synced
${
successCount
}
queued action
${
successCount
>
1
?
's'
:
''
}
`
);
}
if
(
failed
.
length
>
0
)
{
toast
.
error
(
`
${
failed
.
length
}
action
${
failed
.
length
>
1
?
's'
:
''
}
failed to sync. Will retry later.`
);
}
isSyncing
.
current
=
false
;
},
[]);
const
clearQueue
=
useCallback
(()
=>
{
saveQueue
([]);
setQueueLength
(
0
);
},
[]);
return
{
isOnline
,
queueLength
,
enqueue
,
syncQueue
,
clearQueue
};
return
isOnline
;
}
\ 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