Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae |
@@ -0,0 +1,32 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-5 gap-5">
|
||||||
|
{/* Participant */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
1261
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
|
</div>
|
||||||
|
{/* Team */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
{/* Project Submitted */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
project_name: `Project ${i + 1}`,
|
||||||
|
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||||
|
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||||
|
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface SubmissionType {
|
||||||
|
id: number;
|
||||||
|
project_name: string;
|
||||||
|
repository_url: string;
|
||||||
|
demo_url: string;
|
||||||
|
presentation_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = [
|
||||||
|
{
|
||||||
|
header: 'Project Name',
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Repository URL',
|
||||||
|
accessorKey: 'repository_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Demo URL',
|
||||||
|
accessorKey: 'demo_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Presentation URL',
|
||||||
|
accessorKey: 'presentation_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-72') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submission
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||||
|
placeholder="Search name or email"
|
||||||
|
/>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All City</option>
|
||||||
|
<option value="jakarta">Jakarta</option>
|
||||||
|
<option value="bandung">Bandung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals extracted into shared backoffice components */}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useTeams } from '@imphnen-frontend-service/service';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const { data: teamsData } = useTeams();
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: `team-${i + 1}`,
|
||||||
|
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||||
|
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||||
|
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||||
|
member_count: Math.floor(Math.random() * 4) + 1,
|
||||||
|
has_submission: i % 3 !== 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader: {
|
||||||
|
user: {
|
||||||
|
fullname: `Leader User ${i}`,
|
||||||
|
email: `leader${i}@example.com`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface TeamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
member_count: number;
|
||||||
|
has_submission: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
leader?: {
|
||||||
|
user: {
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<TeamType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
isPublic
|
||||||
|
? 'bg-info-200 text-info-700'
|
||||||
|
: 'bg-gray-200 text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'member_count',
|
||||||
|
header: 'Members',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const leader = row.original.leader?.user;
|
||||||
|
return leader ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{leader.fullname}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'has_submission',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasSubmission = row.original.has_submission;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
hasSubmission
|
||||||
|
? 'bg-success-200 text-success-700'
|
||||||
|
: 'bg-danger-200 text-danger-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSubmission ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: 'Last Updated',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return new Date(row.original.updated_at).toLocaleDateString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||||
|
placeholder="Search name or email"
|
||||||
|
/>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All City</option>
|
||||||
|
<option value="jakarta">Jakarta</option>
|
||||||
|
<option value="bandung">Bandung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
{/* Modals extracted into shared backoffice components */}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
+651
@@ -0,0 +1,651 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: string;
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean;
|
||||||
|
skills: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: UserType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||||
|
const [formData, setFormData] = useState<UserType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (user) {
|
||||||
|
// Edit existing user
|
||||||
|
setFormData({ ...user });
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
setFormData({
|
||||||
|
id: '', // Will be generated by backend
|
||||||
|
fullname: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
is_active: true,
|
||||||
|
skills: [],
|
||||||
|
avatar: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!user) return true; // New user always has changes
|
||||||
|
return (
|
||||||
|
formData.fullname !== user.fullname ||
|
||||||
|
formData.location !== user.location ||
|
||||||
|
formData.is_active !== user.is_active ||
|
||||||
|
formData.avatar !== user.avatar ||
|
||||||
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||||
|
formData.bio !== user.bio
|
||||||
|
);
|
||||||
|
}, [formData, user]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof UserType,
|
||||||
|
value: string | boolean | string[] | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsChange = (skills: string[]) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update existing user
|
||||||
|
console.log('Update user data:', formData);
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
console.log('Create new user:', formData);
|
||||||
|
}
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user }); // Reset to original for edit mode
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
if (!user) return; // Can't delete new user
|
||||||
|
console.log('Delete user:', user.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const avatarUrl = e.target?.result as string;
|
||||||
|
handleInputChange('avatar', avatarUrl);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
handleInputChange('avatar', undefined);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableSkills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Hidden File Input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Interactive User Avatar */}
|
||||||
|
<div className="relative group ">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.avatar ? (
|
||||||
|
<img
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Hover Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||||
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar Menu Dropdown */}
|
||||||
|
{showAvatarMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={triggerFileUpload}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||||
|
</button>
|
||||||
|
{formData.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{user ? 'Edit User Profile' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||||
|
Hover avatar to change
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{user
|
||||||
|
? `Make changes to ${
|
||||||
|
formData.fullname || 'this user'
|
||||||
|
}'s profile information`
|
||||||
|
: 'Fill in the information below to create a new user account'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Left Column - Basic Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Full Name - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Full Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('fullname', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{formData.fullname.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Full name is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('location', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
|
formData.location.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Select location</option>
|
||||||
|
<option value="Jakarta">Jakarta</option>
|
||||||
|
<option value="Bandung">Bandung</option>
|
||||||
|
<option value="Surabaya">Surabaya</option>
|
||||||
|
<option value="Medan">Medan</option>
|
||||||
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
|
</select>
|
||||||
|
{formData.location.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Location is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Joined Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(formData.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
|
Bio{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('bio', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Skills & Status */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status - Enhanced Tab Design */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-neutral-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Inactive
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{formData.is_active
|
||||||
|
? 'User can access their account and participate in activities'
|
||||||
|
: 'User account is suspended and cannot access services'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Skills & Expertise{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||||
|
{formData.skills.length > 0 ? (
|
||||||
|
formData.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSkillsChange(
|
||||||
|
formData.skills.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
No skills added yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!formData.skills.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
handleSkillsChange([
|
||||||
|
...formData.skills,
|
||||||
|
e.target.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Add a skill...</option>
|
||||||
|
{availableSkills
|
||||||
|
.filter((skill) => !formData.skills.includes(skill))
|
||||||
|
.map((skill) => (
|
||||||
|
<option key={skill} value={skill}>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
User ID
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-neutral-800">
|
||||||
|
{formData.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{new Date(formData.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{canSave
|
||||||
|
? 'Ready to save changes'
|
||||||
|
: hasChanges
|
||||||
|
? 'Please fill required fields'
|
||||||
|
: 'No changes made'}
|
||||||
|
</div>
|
||||||
|
{/* Delete Account Button - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6',
|
||||||
|
!canSave && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SaveOutlined className="text-sm" />
|
||||||
|
{user ? 'Save Changes' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to permanently delete{' '}
|
||||||
|
<strong>{formData.fullname}</strong>'s account? This will
|
||||||
|
remove all their data and cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUserDetail;
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||||
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
// Removed unused SearchOutlined icon after schema revision
|
||||||
|
|
||||||
|
// Define interface outside component
|
||||||
|
interface UserType {
|
||||||
|
id: string; // UUID
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean; // admin can deactivate
|
||||||
|
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mock data outside component to prevent recreation
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||||
|
const bios = [
|
||||||
|
'Passionate developer with 5+ years experience',
|
||||||
|
'Tech enthusiast and problem solver',
|
||||||
|
'Building scalable solutions for modern problems',
|
||||||
|
'Creative designer with technical background',
|
||||||
|
'Data-driven decision maker',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||||
|
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||||
|
const randomSkills = skillsOptions
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.slice(0, randomSkillsCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||||
|
avatar:
|
||||||
|
i % 4 === 0
|
||||||
|
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||||
|
)}&background=random`
|
||||||
|
: undefined,
|
||||||
|
fullname:
|
||||||
|
i % 3 === 0
|
||||||
|
? 'Ahmad Wijuana'
|
||||||
|
: i % 3 === 1
|
||||||
|
? 'Sofia Wijuana'
|
||||||
|
: 'Budi Santoso',
|
||||||
|
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||||
|
location: locations[i % locations.length],
|
||||||
|
is_active: i % 7 !== 0, // More realistic distribution
|
||||||
|
skills: randomSkills,
|
||||||
|
created_at: new Date(
|
||||||
|
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||||
|
).toISOString(), // Random within last 30-60 days
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [locationFilter, setLocationFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return mockData.filter((user) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location filter
|
||||||
|
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
user.skills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statusFilter, locationFilter, skillsFilter]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{row.original.avatar ? (
|
||||||
|
<img
|
||||||
|
src={row.original.avatar}
|
||||||
|
alt={row.original.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Name only */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{row.original.skills.length > 2 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||||
|
+{row.original.skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aActive = rowA.original.is_active;
|
||||||
|
const bActive = rowB.original.is_active;
|
||||||
|
if (aActive && !bActive) return -1;
|
||||||
|
if (!aActive && bActive) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm px-4 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle user status - implement later
|
||||||
|
console.log(`Toggle status for ${row.original.fullname}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search users by name or location..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={locationFilter}
|
||||||
|
onChange={(e) => setLocationFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Locations</option>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<option key={location} value={location}>
|
||||||
|
{location}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
locationFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location filter badge */}
|
||||||
|
{locationFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {locationFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setLocationFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setLocationFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination-aware results display */}
|
||||||
|
{filteredData.length > 0 && (
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||||
|
{filteredData.length} users
|
||||||
|
{filteredData.length > pageSize}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New User Modal */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={handleCloseNewUserModal}
|
||||||
|
user={null} // null indicates creating new user
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar
|
||||||
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Sticky top header */}
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||||
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile menu button (shown on small screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { SearchOutlined } from "@ant-design/icons";
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
import {
|
||||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
BackofficeWrapper,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
DataTable,
|
||||||
import { ReactElement, useState } from "react";
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ModalDetailUser } from "./_components/modal/detail";
|
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { ModalDetailUser } from './_components/modal/detail';
|
||||||
|
|
||||||
type UserStatus = 'active' | 'inactive';
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
rating: number
|
rating: number;
|
||||||
status: UserStatus
|
status: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
|||||||
email: 'fullname23@gmail.com',
|
email: 'fullname23@gmail.com',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export default function Components(): ReactElement {
|
export default function Components(): ReactElement {
|
||||||
const TABS = ['mentor', 'mentee'] as const
|
const TABS = ['mentor', 'mentee'] as const;
|
||||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||||
const [showDetail, setShowDetail] = useState(false)
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 9,
|
||||||
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
|
|||||||
const columns: ColumnDef<UserType>[] = [
|
const columns: ColumnDef<UserType>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn("w-20") },
|
meta: { cellClassName: cn('w-20') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn("w-72") },
|
meta: { cellClassName: cn('w-72') },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
<div className="mb-8 flex justify-between items-center">
|
<div className="mb-8 flex justify-between items-center">
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
<For data={TABS}>
|
<For data={TABS}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
variant="text"
|
variant="text"
|
||||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
className={cn(
|
||||||
|
'px-3 py-2 capitalize',
|
||||||
|
activeTab === tab && 'bg-white'
|
||||||
|
)}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Rating</option>
|
<option selected disabled>
|
||||||
|
Rating
|
||||||
|
</option>
|
||||||
<option value="4.5">4.5</option>
|
<option value="4.5">4.5</option>
|
||||||
<option value="5">5</option>
|
<option value="5">5</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Status</option>
|
<option selected disabled>
|
||||||
|
Status
|
||||||
|
</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) return redirect('/auth/login');
|
// if (!session) return redirect('/auth/login');
|
||||||
|
|
||||||
const matchedRoute = mappingRoutePermissions.find(
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
(route) => route.path === pathname
|
(route) => route.path === pathname
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,517 @@
|
|||||||
|
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { decodeCertificateId } from '../../../utils/certificate';
|
||||||
|
import {
|
||||||
|
useTeamById,
|
||||||
|
useTeamSubmission,
|
||||||
|
useAuthStore,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
|
interface DecodedCert {
|
||||||
|
teamId: string;
|
||||||
|
submissionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CertificatePage: FC = (): ReactElement => {
|
||||||
|
const { certId } = useParams<{ certId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||||
|
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const certificateRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||||
|
const [showTemplate, setShowTemplate] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certId) {
|
||||||
|
decodeCertificateId(certId)
|
||||||
|
.then(setDecodedInfo)
|
||||||
|
.catch(() => {
|
||||||
|
setError('Invalid certificate ID');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
// Generate QR Code
|
||||||
|
useEffect(() => {
|
||||||
|
if (certId) {
|
||||||
|
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
|
||||||
|
QRCode.toDataURL(certificateUrl, {
|
||||||
|
width: 200,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(setQrCodeUrl)
|
||||||
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
|
}
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
|
decodedInfo?.teamId || '',
|
||||||
|
!!decodedInfo?.teamId
|
||||||
|
);
|
||||||
|
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||||
|
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
||||||
|
|
||||||
|
const team = teamData?.data;
|
||||||
|
const submission = submissionData?.data;
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
||||||
|
|
||||||
|
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||||
|
useEffect(() => {
|
||||||
|
const adjustFontSize = (
|
||||||
|
element: HTMLElement | null,
|
||||||
|
maxHeight: number,
|
||||||
|
startSize: number,
|
||||||
|
setter: (size: string) => void
|
||||||
|
) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
let currentSize = startSize;
|
||||||
|
element.style.fontSize = `${currentSize}px`;
|
||||||
|
|
||||||
|
while (element.offsetHeight > maxHeight && currentSize > 1) {
|
||||||
|
currentSize -= 2;
|
||||||
|
element.style.fontSize = `${currentSize}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setter(`${currentSize}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||||
|
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [team?.name, session?.user?.fullname]);
|
||||||
|
|
||||||
|
// Generate certificate canvas screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const generateCertificate = async () => {
|
||||||
|
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
// Wait a bit for fonts and images to load
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
logging: false,
|
||||||
|
width: 1000,
|
||||||
|
height: (1000 * 2480) / 3508,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = canvas.toDataURL('image/png');
|
||||||
|
setCertificateImage(imageUrl);
|
||||||
|
setShowTemplate(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate certificate:', error);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateCertificate();
|
||||||
|
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
|
||||||
|
|
||||||
|
// Download certificate
|
||||||
|
const handleDownloadCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = certificateImage;
|
||||||
|
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Print certificate
|
||||||
|
const handlePrintCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${team?.name}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
img { width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${certificateImage}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error || !certId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">❌</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invalid Certificate
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error || 'The certificate ID is invalid or malformed.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submission || submission.id !== decodedInfo?.submissionId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">📄</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Certificate Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The submission associated with this certificate could not be found.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Print Styles */}
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
#certificate-wrapper {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
#certificate, #certificate * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
#certificate {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive - zoom out to fit */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#certificate-container {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Certificate
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{team?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Back to Submission
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Content */}
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
|
id="certificate-wrapper"
|
||||||
|
>
|
||||||
|
{/* Hidden Template for Canvas Generation */}
|
||||||
|
<div
|
||||||
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={certificateRef}
|
||||||
|
id="certificate-template"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundImage: 'url(/images/blank_cert.png)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: '1000px',
|
||||||
|
height: `${(1000 * 2480) / 3508}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* User Name (from session) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '80%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
ref={userNameRef}
|
||||||
|
style={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#111827',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: userNameFontSize,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session?.user?.fullname || 'N/A'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Name */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '46%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '70%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
ref={teamNameRef}
|
||||||
|
style={{
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: teamNameFontSize,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team?.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participation Text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '70%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '500',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>
|
||||||
|
Peserta Hackathon IMPHNEN x KOLOSAL AI
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8%',
|
||||||
|
left: '8%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrCodeUrl && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt="Certificate QR Code"
|
||||||
|
style={{ width: '96px', height: '96px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8%',
|
||||||
|
right: '8%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#374151',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submission.submitted_at
|
||||||
|
? new Date(submission.submitted_at).toLocaleDateString(
|
||||||
|
'id-ID',
|
||||||
|
{
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Certificate Image */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Generating certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certificateImage && !isGenerating && (
|
||||||
|
<img
|
||||||
|
src={certificateImage}
|
||||||
|
alt="Certificate"
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDownloadCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handlePrintCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||||
|
}
|
||||||
|
variant="secondary"
|
||||||
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
View Submission
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Certificate Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
This certificate is a digital record of your hackathon participation
|
||||||
|
and project submission. You can print or save this page as a PDF for
|
||||||
|
your records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificatePage;
|
||||||
@@ -14,6 +14,9 @@ import ProfilePage from '../profile/page';
|
|||||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||||
|
|
||||||
|
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||||
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
type Invitation = {
|
type Invitation = {
|
||||||
id: string;
|
id: string;
|
||||||
team: {
|
team: {
|
||||||
@@ -37,9 +40,46 @@ type Invitation = {
|
|||||||
const DashboardPage: FC = (): ReactElement => {
|
const DashboardPage: FC = (): ReactElement => {
|
||||||
const { session } = useAuthStore();
|
const { session } = useAuthStore();
|
||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||||
|
const [timeLeft, setTimeLeft] = useState<{
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Check if team features are closed
|
// Check if team features are closed
|
||||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||||
|
|
||||||
|
// Check if submission deadline passed
|
||||||
|
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmissionDeadlinePassed) return;
|
||||||
|
|
||||||
|
const calculateTimeLeft = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
setTimeLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||||
|
const seconds = Math.floor((difference / 1000) % 60);
|
||||||
|
|
||||||
|
setTimeLeft({ days, hours, minutes, seconds });
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateTimeLeft();
|
||||||
|
const timer = setInterval(calculateTimeLeft, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isSubmissionDeadlinePassed]);
|
||||||
|
|
||||||
// Lock background scroll when profile modal is open
|
// Lock background scroll when profile modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showProfileModal) {
|
if (showProfileModal) {
|
||||||
@@ -94,6 +134,57 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown Timer */}
|
||||||
|
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||||
|
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<span className="text-3xl">⏰</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||||
|
Submission Deadline
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||||
|
Project submissions close on December 7, 2025 at 23:59 WIB
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.days}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.hours.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Hours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Seconds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invitations.length > 0 && (
|
{invitations.length > 0 && (
|
||||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FC, ReactElement } from 'react';
|
|||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||||
|
import { encodeCertificateId } from '../../../../utils/certificate';
|
||||||
|
|
||||||
const SubmissionViewPage: FC = (): ReactElement => {
|
const SubmissionViewPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
@@ -15,18 +16,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
|
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-6xl mb-4">📄</div>
|
<div className="text-6xl mb-4">📄</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
||||||
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
|
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
|
||||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
: 'Not submitted';
|
: 'Not submitted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
||||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||||
Back to Team
|
Back to Team
|
||||||
@@ -101,9 +102,37 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
{/* Certificate Banner - Only show when submission is submitted */}
|
||||||
|
{submission.status === 'submitted' && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<span className="text-4xl shrink-0">🏆</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||||
|
View Your Certificate
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
|
Congratulations! Your certificate is ready to download and share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const certId = await encodeCertificateId(teamId || '', submission.id);
|
||||||
|
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
||||||
|
}}
|
||||||
|
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Get Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||||
{/* Project Header */}
|
{/* Project Header */}
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||||
<p className="text-blue-100">Team: {team?.name}</p>
|
<p className="text-blue-100">Team: {team?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,8 +142,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
|
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Screenshot ${index + 1}`}
|
alt={`Screenshot ${index + 1}`}
|
||||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -179,11 +208,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submission Info */}
|
{/* Submission Info */}
|
||||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||||
<span className={`font-medium ${
|
<span className={`font-medium ${
|
||||||
submission.status === 'submitted'
|
submission.status === 'submitted'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
@@ -199,11 +228,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
|
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
|
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||||
{submission.id}
|
{submission.id}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC, ReactElement, useState } from 'react';
|
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
@@ -14,10 +14,14 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||||
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
const SubmitProjectPage: FC = (): ReactElement => {
|
const SubmitProjectPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -25,6 +29,42 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||||
|
const [timeLeft, setTimeLeft] = useState<{
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Check if deadline passed
|
||||||
|
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDeadlinePassed) return;
|
||||||
|
|
||||||
|
const calculateTimeLeft = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
setTimeLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||||
|
const seconds = Math.floor((difference / 1000) % 60);
|
||||||
|
|
||||||
|
setTimeLeft({ days, hours, minutes, seconds });
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateTimeLeft();
|
||||||
|
const timer = setInterval(calculateTimeLeft, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isDeadlinePassed]);
|
||||||
|
|
||||||
const { data: teamData } = useTeamById(teamId || '');
|
const { data: teamData } = useTeamById(teamId || '');
|
||||||
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||||
@@ -85,6 +125,50 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show deadline passed screen
|
||||||
|
if (isDeadlinePassed) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:clock-alert"
|
||||||
|
className="text-3xl text-red-600 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Submission Closed
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Project submissions are no longer accepted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
The submission deadline was December 7, 2025 at 23:59 WIB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/teams/${teamId}`)}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Back to Team
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleScreenshotUpload = async (
|
const handleScreenshotUpload = async (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement>
|
||||||
) => {
|
) => {
|
||||||
@@ -138,6 +222,57 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Countdown Timer */}
|
||||||
|
{timeLeft && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<span className="text-3xl">⏰</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||||
|
Submission Deadline
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||||
|
Submissions close on December 7, 2025 at 23:59 WIB
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.days}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.hours.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Hours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Seconds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Minimum Members Warning */}
|
{/* Minimum Members Warning */}
|
||||||
{!hasEnoughMembers && (
|
{!hasEnoughMembers && (
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||||
@@ -200,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||||
Project Description <span className="text-red-500">*</span>
|
Project Description <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
|
||||||
|
</p>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
@@ -207,7 +345,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
<div>
|
<div>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Describe your project, its features, and what problem it solves..."
|
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useWinners } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
const WinnerPage: FC = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error } = useWinners();
|
||||||
|
|
||||||
|
const winners = data?.data ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading winners...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Error Loading Winners
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Unable to load winners at this time. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort winners by rank
|
||||||
|
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||||
|
|
||||||
|
// Medal emojis for top 3
|
||||||
|
const getMedalEmoji = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return '🥇';
|
||||||
|
case 2:
|
||||||
|
return '🥈';
|
||||||
|
case 3:
|
||||||
|
return '🥉';
|
||||||
|
default:
|
||||||
|
return '🏆';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get rank color
|
||||||
|
const getRankColor = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return 'from-yellow-400 to-yellow-600';
|
||||||
|
case 2:
|
||||||
|
return 'from-gray-300 to-gray-500';
|
||||||
|
case 3:
|
||||||
|
return 'from-amber-600 to-amber-800';
|
||||||
|
default:
|
||||||
|
return 'from-blue-500 to-blue-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl mb-4">🏆</div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Hackathon Winners
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Congratulations to all the winning teams!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Winners List */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{winners.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="text-6xl mb-4">🎯</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
No Winners Announced Yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Winners will be announced here once the hackathon concludes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Top 3 Winners - Mobile View */}
|
||||||
|
<div className="md:hidden space-y-6">
|
||||||
|
{[1, 2, 3].map((position) => {
|
||||||
|
const winner = sortedWinners[position - 1];
|
||||||
|
|
||||||
|
if(!winner) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
|
||||||
|
getRankColor(winner.rank).includes('yellow')
|
||||||
|
? 'border-4 border-yellow-400 dark:border-yellow-600'
|
||||||
|
: getRankColor(winner.rank).includes('gray')
|
||||||
|
? 'border-4 border-gray-400 dark:border-gray-600'
|
||||||
|
: 'border-4 border-amber-600 dark:border-amber-500'
|
||||||
|
}}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
|
||||||
|
winner.rank
|
||||||
|
)} flex items-center justify-center text-white font-bold text-2xl`}
|
||||||
|
>
|
||||||
|
<div className="text-4xl">
|
||||||
|
{getMedalEmoji(winner.rank)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{winner.team.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
||||||
|
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||||
|
{[2, 1, 3].map((position) => {
|
||||||
|
const winner = sortedWinners[position - 1];
|
||||||
|
if(!winner) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className="w-full max-w-xs flex flex-col items-center space-y-4"
|
||||||
|
>
|
||||||
|
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||||
|
|
||||||
|
{/* Team Logo */}
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center px-2">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{winner.team.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Podium */}
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
winner.rank === 1
|
||||||
|
? 'h-42 bg-yellow-500'
|
||||||
|
: winner.rank === 2
|
||||||
|
? 'h-32 bg-gray-400'
|
||||||
|
: 'h-16 bg-amber-600'
|
||||||
|
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
|
||||||
|
>
|
||||||
|
<div className="text-white font-bold text-3xl pb-4">
|
||||||
|
#{winner.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ranks 4-23: Prize Winners */}
|
||||||
|
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||||
|
0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||||
|
Favorite
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sortedWinners
|
||||||
|
.filter((w) => w.rank >= 4 && w.rank <= 23)
|
||||||
|
.map((winner) => (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
#{winner.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white truncate">
|
||||||
|
{winner.team.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
|
||||||
|
🎁 Prize Winner
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rank 24+: Remaining Participants */}
|
||||||
|
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||||
|
All Participants
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sortedWinners
|
||||||
|
.filter((w) => w.rank >= 24)
|
||||||
|
.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="shrink-0 w-10 text-center">
|
||||||
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
#{participant.rank}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participant.team.logo && (
|
||||||
|
<img
|
||||||
|
src={participant.team.logo}
|
||||||
|
alt={`${participant.team.name} logo`}
|
||||||
|
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{participant.team.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{participant.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
{winners.length > 0 && (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Congratulations! 🎉
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
Thank you to all participants for making this hackathon a success.
|
||||||
|
Every project and idea contributed to an incredible showcase of
|
||||||
|
innovation and creativity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WinnerPage;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const enc = new TextEncoder();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
|
function randBytes(len: number): Uint8Array {
|
||||||
|
const b = new Uint8Array(len);
|
||||||
|
crypto.getRandomValues(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufToBase64(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBuf(b64: string): ArrayBuffer {
|
||||||
|
const s = atob(b64);
|
||||||
|
const arr = new Uint8Array(s.length);
|
||||||
|
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
|
||||||
|
return arr.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) {
|
||||||
|
const passKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(passphrase),
|
||||||
|
{ name: 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
passKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
|
||||||
|
*/
|
||||||
|
export async function encryptText(plaintext: string, passphrase: string) {
|
||||||
|
const salt = randBytes(16); // 128-bit salt
|
||||||
|
const iv = randBytes(12); // 96-bit IV recommended for GCM
|
||||||
|
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
|
||||||
|
const cipher = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
enc.encode(plaintext)
|
||||||
|
);
|
||||||
|
|
||||||
|
// concat salt + iv + ciphertext
|
||||||
|
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
||||||
|
out.set(salt, 0);
|
||||||
|
out.set(iv, salt.length);
|
||||||
|
out.set(new Uint8Array(cipher), salt.length + iv.length);
|
||||||
|
|
||||||
|
return bufToBase64(out.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
|
||||||
|
*/
|
||||||
|
export async function decryptText(b64combined: string, passphrase: string) {
|
||||||
|
const combined = new Uint8Array(base64ToBuf(b64combined));
|
||||||
|
const salt = combined.slice(0, 16);
|
||||||
|
const iv = combined.slice(16, 28);
|
||||||
|
const cipher = combined.slice(28);
|
||||||
|
|
||||||
|
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
const plainBuf = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipher
|
||||||
|
);
|
||||||
|
return dec.decode(plainBuf);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { decryptText, encryptText } from "./aesclient";
|
||||||
|
|
||||||
|
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode teamId and submissionId into a certificate ID
|
||||||
|
* Uses base64 encoding for simple obfuscation
|
||||||
|
* @param teamId - The team ID
|
||||||
|
* @param submissionId - The submission ID
|
||||||
|
* @returns Encoded certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
|
||||||
|
const combined = `${teamId}::${submissionId}`;
|
||||||
|
return encryptText(combined, SECRET_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode certificate ID back to teamId and submissionId
|
||||||
|
* @param certId - The encoded certificate ID
|
||||||
|
* @returns Object containing teamId and submissionId
|
||||||
|
*/
|
||||||
|
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
|
||||||
|
try {
|
||||||
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
|
const [teamId, submissionId] = decoded.split('::');
|
||||||
|
return { teamId, submissionId };
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid certificate ID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For development: Create a certId using created_at timestamp
|
||||||
|
* @param teamId - The team ID
|
||||||
|
* @param createdAt - The creation timestamp
|
||||||
|
* @returns Encoded certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
||||||
|
const combined = `${teamId}::${createdAt}`;
|
||||||
|
return Buffer.from(combined).toString('base64');
|
||||||
|
};
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# Hackathon Backoffice API Contract
|
||||||
|
|
||||||
|
## GET /api/v1/admin/dashboard
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Single endpoint delivering aggregated metrics for the IMPHNEN x Kolosal.ai Hackathon dashboard.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"total_participants": 1261,
|
||||||
|
"total_teams": 206,
|
||||||
|
"total_submissions": 0 // Total project submitted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Types
|
||||||
|
|
||||||
|
| Path | Type | Notes |
|
||||||
|
| ------------------------- | ------- | --------------------- |
|
||||||
|
| `data.total_participants` | integer | >= 0 |
|
||||||
|
| `data.total_teams` | integer | >= 0 |
|
||||||
|
| `data.total_submissions` | integer | <= `data.total_teams` |
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
| Status | Code | Message | Notes |
|
||||||
|
| ------ | ---------------- | ----------------------------- | --------------------- |
|
||||||
|
| 401 | `unauthorized` | `authentication required` | Missing/invalid token |
|
||||||
|
| 403 | `forbidden` | `insufficient permissions` | Lacks required scope |
|
||||||
|
| 429 | `rate_limited` | `too many dashboard requests` | Rate limiting |
|
||||||
|
| 500 | `internal_error` | `unexpected server error` | Unhandled exception |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/admin/users
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Retrieve paginated list of hackathon participants with filtering, searching, and sorting capabilities for backoffice user management.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
| ------------ | ------- | -------- | ------------ | ------------------------------------------------------------- |
|
||||||
|
| `page` | integer | No | 1 | Page number (1-based) |
|
||||||
|
| `limit` | integer | No | 10 | Items per page (1-100) |
|
||||||
|
| `search` | string | No | - | Search by name or location (case-insensitive) |
|
||||||
|
| `status` | string | No | `all` | Filter by status: `all`, `active`, `inactive` |
|
||||||
|
| `location` | string | No | `all` | Filter by location or `all` |
|
||||||
|
| `skills` | string | No | - | Comma-separated skill filters |
|
||||||
|
| `sort_by` | string | No | `created_at` | Sort field: `fullname`, `location`, `is_active`, `created_at` |
|
||||||
|
| `sort_order` | string | No | `desc` | Sort order: `asc`, `desc` |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/users
|
||||||
|
GET /api/v1/admin/users?page=2&limit=10
|
||||||
|
GET /api/v1/admin/users?search=john&status=active
|
||||||
|
GET /api/v1/admin/users?location=Jakarta&skills=Frontend Developer,UI/UX Designer
|
||||||
|
GET /api/v1/admin/users?sort_by=fullname&sort_order=asc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"avatar": "https://example.com/avatars/user1.jpg", // Optional
|
||||||
|
"fullname": "Budi Santoso",
|
||||||
|
"bio": "Passionate developer with 5+ years experience", // Optional
|
||||||
|
"location": "Jakarta",
|
||||||
|
"is_active": true,
|
||||||
|
"skills": ["Frontend Developer", "UI/UX Designer"], // Optional
|
||||||
|
"created_at": "2024-11-15T08:30:00Z",
|
||||||
|
"updated_at": "2024-11-30T14:22:00Z"
|
||||||
|
}
|
||||||
|
// ... more users
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"current_page": 1,
|
||||||
|
"total_pages": 15,
|
||||||
|
"total_items": 287,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_next": true,
|
||||||
|
"has_prev": false
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"available_locations": ["Jakarta", "Bandung", "Surabaya", "Medan", "Yogyakarta"],
|
||||||
|
"available_skills": ["Frontend Developer", "Backend Developer", "Full Stack Developer", "DevOps Engineer", "UI/UX Designer", "Product Manager", "Data Scientist", "Mobile Developer"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Types
|
||||||
|
|
||||||
|
| Path | Type | Notes |
|
||||||
|
| ------------------------- | ------- | ------------------------------ |
|
||||||
|
| `data.users[].id` | string | UUID format |
|
||||||
|
| `data.users[].avatar` | string | URL, nullable |
|
||||||
|
| `data.users[].fullname` | string | Required |
|
||||||
|
| `data.users[].bio` | string | Optional, max 500 chars |
|
||||||
|
| `data.users[].location` | string | Required, from predefined list |
|
||||||
|
| `data.users[].is_active` | boolean | Account status |
|
||||||
|
| `data.users[].skills` | array | Array of skill strings |
|
||||||
|
| `data.users[].created_at` | string | ISO 8601 timestamp |
|
||||||
|
| `data.users[].updated_at` | string | ISO 8601 timestamp |
|
||||||
|
| `data.pagination.*` | integer | Pagination metadata |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/admin/users/{user_id}
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Retrieve detailed information for a specific user by ID.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ----------- |
|
||||||
|
| `user_id` | string | Yes | User UUID |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"avatar": "https://example.com/avatars/user1.jpg",
|
||||||
|
"fullname": "Budi Santoso",
|
||||||
|
"bio": "Passionate developer with 5+ years experience",
|
||||||
|
"location": "Jakarta",
|
||||||
|
"is_active": true,
|
||||||
|
"skills": ["Frontend Developer", "UI/UX Designer"],
|
||||||
|
"created_at": "2024-11-15T08:30:00Z",
|
||||||
|
"updated_at": "2024-11-30T14:22:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/v1/admin/users
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Create a new user account in the hackathon system.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
|
||||||
|
### Request Body Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"fullname": "Jane Doe", // Required, 1-100 chars
|
||||||
|
"bio": "Experienced developer", // Optional, max 500 chars
|
||||||
|
"location": "Jakarta", // Required, from predefined list
|
||||||
|
"is_active": true, // Required, boolean
|
||||||
|
"skills": ["Backend Developer"], // Optional, array of valid skills
|
||||||
|
"avatar": "https://example.com/images/..." // Optional, URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/users
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fullname": "Jane Doe",
|
||||||
|
"bio": "Experienced developer passionate about AI and machine learning",
|
||||||
|
"location": "Jakarta",
|
||||||
|
"is_active": true,
|
||||||
|
"skills": ["Backend Developer", "Data Scientist"],
|
||||||
|
"avatar": "https://example.com/images/..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"avatar": "https://example.com/avatars/generated_url.jpg",
|
||||||
|
"fullname": "Jane Doe",
|
||||||
|
"bio": "Experienced developer passionate about AI and machine learning",
|
||||||
|
"location": "Jakarta",
|
||||||
|
"is_active": true,
|
||||||
|
"skills": ["Backend Developer", "Data Scientist"],
|
||||||
|
"created_at": "2024-12-01T10:30:00Z",
|
||||||
|
"updated_at": "2024-12-01T10:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/v1/admin/users/{user_id}
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Update an existing user's profile information.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ----------- |
|
||||||
|
| `user_id` | string | Yes | User UUID |
|
||||||
|
|
||||||
|
### Request Body Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"fullname": "Jane Smith", // Optional, 1-100 chars
|
||||||
|
"bio": "Senior developer", // Optional, max 500 chars, null to clear
|
||||||
|
"location": "Bandung", // Optional, from predefined list
|
||||||
|
"is_active": false, // Optional, boolean
|
||||||
|
"skills": ["Full Stack Developer"], // Optional, array of valid skills
|
||||||
|
"avatar": "https://example.com/images/..." // Optional, URL, null to remove
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fullname": "Jane Smith",
|
||||||
|
"location": "Bandung",
|
||||||
|
"is_active": false,
|
||||||
|
"skills": ["Full Stack Developer", "Product Manager"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"avatar": "https://example.com/avatars/user1.jpg",
|
||||||
|
"fullname": "Jane Smith",
|
||||||
|
"bio": "Experienced developer passionate about AI and machine learning",
|
||||||
|
"location": "Bandung",
|
||||||
|
"is_active": false,
|
||||||
|
"skills": ["Full Stack Developer", "Product Manager"],
|
||||||
|
"created_at": "2024-11-15T08:30:00Z",
|
||||||
|
"updated_at": "2024-12-01T10:45:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /api/v1/admin/users/{user_id}
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
(Soft) Delete a user account from the hackathon system.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ----------- |
|
||||||
|
| `user_id` | string | Yes | User UUID |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"message": "User successfully deleted",
|
||||||
|
"deleted_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"deleted_at": "2024-12-01T10:50:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Error Responses
|
||||||
|
|
||||||
|
### User Management Endpoints
|
||||||
|
|
||||||
|
| Status | Code | Message | Notes |
|
||||||
|
| ------ | --------------------- | -------------------------------- | ---------------------------- |
|
||||||
|
| 400 | `validation_error` | `Invalid request data` | Field validation failures |
|
||||||
|
| 401 | `unauthorized` | `Authentication required` | Missing/invalid token |
|
||||||
|
| 403 | `forbidden` | `Insufficient permissions` | Lacks required scope |
|
||||||
|
| 404 | `user_not_found` | `User not found` | Invalid user ID |
|
||||||
|
| 409 | `user_already_exists` | `User with email already exists` | Duplicate user creation |
|
||||||
|
| 413 | `payload_too_large` | `Avatar file too large` | Avatar exceeds size limit |
|
||||||
|
| 422 | `invalid_skill` | `Invalid skill specified` | Skill not in allowed list |
|
||||||
|
| 422 | `invalid_location` | `Invalid location specified` | Location not in allowed list |
|
||||||
|
| 429 | `rate_limited` | `Too many requests` | Rate limiting |
|
||||||
|
| 500 | `internal_error` | `Unexpected server error` | Unhandled exception |
|
||||||
|
|
||||||
|
### Validation Error Details
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Invalid request data",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"field": "fullname",
|
||||||
|
"code": "required",
|
||||||
|
"message": "Full name is required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "location",
|
||||||
|
"code": "invalid_choice",
|
||||||
|
"message": "Location must be one of: Jakarta, Bandung, Surabaya, Medan, Yogyakarta"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
- **Dashboard**: 30 requests / minute / admin user
|
||||||
|
- **User Management**: 100 requests / minute / admin user
|
||||||
|
- **File Upload**: 10 avatar uploads / minute / admin user
|
||||||
|
- Return 429 with `Retry-After` header
|
||||||
|
|
||||||
|
## Avatar Handling
|
||||||
|
|
||||||
|
- **Supported formats**: JPEG, PNG, WebP
|
||||||
|
- **Max file size**: 5MB
|
||||||
|
- **Recommended dimensions**: 400x400px
|
||||||
|
- **Storage**: Uploaded avatars are processed and stored with generated URLs
|
||||||
|
- **URL response**: Always return publicly accessible HTTPS URLs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Revision History**
|
||||||
|
|
||||||
|
- v1.0.0 (2025-11-30): Initial contract drafted.
|
||||||
|
- v2.0.0 (2025-12-01): Added user management endpoints with filtering, pagination, CRUD operations, and avatar handling.
|
||||||
@@ -5,3 +5,4 @@ export * from './mentors';
|
|||||||
export * from './upload';
|
export * from './upload';
|
||||||
export * from './teams';
|
export * from './teams';
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
|
export * from './winners';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const winnerKeys = {
|
||||||
|
all: ['winners'] as const,
|
||||||
|
lists: () => [...winnerKeys.all, 'list'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
city: string;
|
||||||
|
visibility: string;
|
||||||
|
logo: string;
|
||||||
|
banner: string;
|
||||||
|
leader_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Winner {
|
||||||
|
id: string;
|
||||||
|
team_id: string;
|
||||||
|
team: Team;
|
||||||
|
rank: number;
|
||||||
|
prize: string;
|
||||||
|
announced_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWinners = () => {
|
||||||
|
return useQuery<HackathonApiResponse<Winner[]>>({
|
||||||
|
queryKey: winnerKeys.lists(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await hackathonApi.get('/winners');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,64 +3,257 @@ import {
|
|||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
|
DownOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
ReadOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RightOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
StockOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
UserSwitchOutlined,
|
UserSwitchOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button } from '../../atoms';
|
import { Button } from '../../atoms';
|
||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
const MENUS = [
|
type MenuItem = {
|
||||||
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
|
label: string;
|
||||||
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
|
href?: string;
|
||||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
|
icon?: ReactElement;
|
||||||
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
|
||||||
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
|
};
|
||||||
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
|
|
||||||
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
const MENUS: MenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Hackathon',
|
||||||
|
icon: <StockOutlined className="text-p3" />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/hackathon-dashboard',
|
||||||
|
icon: <AppstoreOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Users',
|
||||||
|
href: '/hackathon-users',
|
||||||
|
icon: <UserOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Teams',
|
||||||
|
href: '/hackathon-teams',
|
||||||
|
icon: <UsergroupAddOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Submissions',
|
||||||
|
href: '/hackathon-submissions',
|
||||||
|
icon: <AuditOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dimentorin',
|
||||||
|
icon: <ReadOutlined className="text-p3" />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard - Dimentorin',
|
||||||
|
href: '/dashboard-dimentorin',
|
||||||
|
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'User - Dimentorin',
|
||||||
|
href: '/users-dimentorin',
|
||||||
|
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Session - Dimentorin',
|
||||||
|
href: '/session-dimentorin',
|
||||||
|
icon: <ScheduleOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content & Roadmap',
|
||||||
|
href: '/roadmap-dimentorin',
|
||||||
|
icon: <BookOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feedback & Review',
|
||||||
|
href: '/feedback-review-dimentorin',
|
||||||
|
icon: <CommentOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings - Dimentorin',
|
||||||
|
href: '/settings-dimentorin',
|
||||||
|
icon: <SettingOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gacha',
|
||||||
|
icon: <ReloadOutlined className="text-[20px]" />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard & Set Gacha',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gacha Roll',
|
||||||
|
href: '/gacha-roll',
|
||||||
|
icon: <ReloadOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Validasi Transaksi',
|
||||||
|
href: '/transactions',
|
||||||
|
icon: <AuditOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data Pengiriman Hadiah',
|
||||||
|
href: '/prizes',
|
||||||
|
icon: <InboxOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Permissions',
|
||||||
|
href: '/permissions',
|
||||||
|
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Roles',
|
||||||
|
href: '/roles',
|
||||||
|
icon: <UsergroupAddOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data Akun',
|
||||||
|
href: '/accounts',
|
||||||
|
icon: <UserOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackofficeSidebar: FC<SidebarProps> = ({
|
||||||
|
isOpen = false,
|
||||||
|
onClose,
|
||||||
|
}): ReactElement => {
|
||||||
const { signOut } = useSession();
|
const { signOut } = useSession();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
|
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
||||||
return location.pathname.includes(path)
|
return false;
|
||||||
|
return location.pathname.includes(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const toggleGroup = (groupLabel: string) => {
|
||||||
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
|
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
|
||||||
<div className="flex flex-col gap-20 justify-between items-center">
|
};
|
||||||
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
|
||||||
|
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
|
||||||
|
<div className="flex justify-around lg:justify-center items-center w-full">
|
||||||
|
<img
|
||||||
|
src="/logos/simple.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
||||||
<For data={MENUS}>
|
<For data={MENUS}>
|
||||||
{({ label, href, icon }) => (
|
{(menu) =>
|
||||||
<Link
|
menu.children && menu.children.length > 0 ? (
|
||||||
key={href}
|
<div key={menu.label} className="w-full">
|
||||||
to={href}
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
|
onClick={() => toggleGroup(menu.label)}
|
||||||
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
|
className={cn(
|
||||||
)}
|
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
|
||||||
>
|
openGroups[menu.label]
|
||||||
{icon}
|
? 'bg-primary-400 hover:bg-primary-500 text-white'
|
||||||
<span className="text-p3 font-medium">{label}</span>
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
</Link>
|
)}
|
||||||
)}
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{menu.icon}
|
||||||
|
<span className="text-p3 font-medium">{menu.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-label2">
|
||||||
|
{openGroups[menu.label] ? (
|
||||||
|
<DownOutlined className="text-label1" />
|
||||||
|
) : (
|
||||||
|
<RightOutlined className="text-label1" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openGroups[menu.label] && (
|
||||||
|
<div className="mt-2 ml-6 flex flex-col gap-2">
|
||||||
|
{menu.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.href}
|
||||||
|
to={child.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-2.5 rounded-md',
|
||||||
|
isActive(child.href)
|
||||||
|
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{child.icon}
|
||||||
|
<span className="text-label1 font-medium">
|
||||||
|
{child.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={menu.href ?? menu.label}
|
||||||
|
to={menu.href ?? '#'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-items-start gap-3 px-2 py-2.5',
|
||||||
|
menu.href && isActive(menu.href)
|
||||||
|
? 'bg-primary-500 text-white rounded-md'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menu.icon}
|
||||||
|
<span className="text-p3 font-medium">{menu.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
</For>
|
</For>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
variant="text"
|
variant="text"
|
||||||
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
|
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||||
>
|
>
|
||||||
<LogoutOutlined className="text-[20px]" />
|
<LogoutOutlined className="text-p3" />
|
||||||
<span className="text-p3 font-medium">Log Out</span>
|
<span className="text-p3 font-medium">Log Out</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar - visible on lg+, sticky */}
|
||||||
|
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar - overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react';
|
||||||
import { FC, ReactElement, ReactNode } from "react"
|
import { FC, ReactElement, ReactNode } from 'react';
|
||||||
import { Button } from "../../atoms"
|
import { Button } from '../../atoms';
|
||||||
|
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export type TBackofficeWrapperProps = {
|
export type TBackofficeWrapperProps = {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
title?: string
|
title?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
classHeader?: string
|
classHeader?: string;
|
||||||
classTitle?: string
|
classTitle?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||||
children,
|
children,
|
||||||
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
|||||||
classHeader,
|
classHeader,
|
||||||
classTitle,
|
classTitle,
|
||||||
}): ReactElement => {
|
}): ReactElement => {
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
|
<main
|
||||||
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
|
className={cn(
|
||||||
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
|
'w-full px-[48px] py-[40px] flex flex-col gap-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
|
||||||
|
classHeader
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
'text-[19px] text-primary-500 font-semibold',
|
||||||
|
classTitle
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-6">
|
<div className="flex items-center gap-x-6">
|
||||||
<Button
|
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="max-h-full p-3"
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:bell-outline" className="size-6" />
|
<Icon icon="mdi:bell-outline" className="size-6" />
|
||||||
</Button>
|
</Button> */}
|
||||||
<div className="flex items-center gap-x-6">
|
<div className="flex items-center gap-x-6">
|
||||||
<div className="text-neutral-600 font-medium">
|
<div className="text-neutral-600 font-medium">
|
||||||
<p className="text-p3">Rizal Syaepulloh</p>
|
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
||||||
<p className="text-label1">Super Admin</p>
|
<p className="text-label1">Admin</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="size-12 rounded-full overflow-hidden">
|
<div className="size-12 rounded-full overflow-hidden">
|
||||||
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
|
<img
|
||||||
|
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||||
|
alt="Profile"
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>{children}</section>
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,57 +1,113 @@
|
|||||||
import {
|
import {
|
||||||
PaginationState,
|
PaginationState,
|
||||||
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
Table,
|
Table,
|
||||||
RowData,
|
RowData,
|
||||||
|
TableOptions,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { Pagination } from '../../molecules';
|
import { Pagination } from '../../molecules';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
interface DataTableProps<T> {
|
interface DataTableProps<T extends RowData> {
|
||||||
data: T[];
|
table?: Table<T>;
|
||||||
columns: ColumnDef<T>[];
|
data?: T[];
|
||||||
table: Table<T>;
|
columns?: ColumnDef<T, unknown>[];
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTable = <T extends RowData>({
|
export const DataTable = <T extends RowData>({
|
||||||
data,
|
table,
|
||||||
columns,
|
data = [],
|
||||||
|
columns = [],
|
||||||
pageSize = 9,
|
pageSize = 9,
|
||||||
|
className,
|
||||||
}: DataTableProps<T>) => {
|
}: DataTableProps<T>) => {
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
const table = useReactTable({
|
// Update pagination state when pageSize prop changes
|
||||||
data,
|
React.useEffect(() => {
|
||||||
columns,
|
setPagination((prev) => ({
|
||||||
state: {
|
...prev,
|
||||||
pagination,
|
pageSize,
|
||||||
},
|
}));
|
||||||
getCoreRowModel: getCoreRowModel(),
|
}, [pageSize]);
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onPaginationChange: setPagination,
|
// Reset pagination when data changes to prevent out-of-bounds errors
|
||||||
});
|
React.useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: 0, // Reset to first page when data changes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [data.length]);
|
||||||
|
|
||||||
|
// Memoize data and columns to prevent unnecessary re-renders
|
||||||
|
const memoizedData = React.useMemo(() => data, [data]);
|
||||||
|
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
||||||
|
|
||||||
|
// Memoize table configuration to prevent recreation on every render
|
||||||
|
const tableConfig = React.useMemo(() => {
|
||||||
|
const config: TableOptions<T> = {
|
||||||
|
data: memoizedData,
|
||||||
|
columns: memoizedColumns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}, [memoizedData, memoizedColumns, pagination, sorting]);
|
||||||
|
|
||||||
|
// Prefer external table instance if provided; otherwise create an internal one
|
||||||
|
const internalTable = useReactTable(tableConfig);
|
||||||
|
const t = table ?? internalTable;
|
||||||
|
|
||||||
|
// Handle empty data state
|
||||||
|
const isEmpty = t.getRowModel().rows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className={cn('flex flex-col gap-8', className)}>
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-full text-base">
|
<table className="w-full min-w-full text-base">
|
||||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{t.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
|
onClick={
|
||||||
|
header.column.getCanSort()
|
||||||
|
? header.column.getToggleSortingHandler()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
|
||||||
|
header.column.getCanSort() &&
|
||||||
|
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
|
||||||
|
header?.column?.columnDef?.meta?.headerClassName
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@@ -59,28 +115,58 @@ export const DataTable = <T extends RowData>({
|
|||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
|
{header.column.getIsSorted() === 'asc' && '▲'}
|
||||||
|
{header.column.getIsSorted() === 'desc' && '▼'}
|
||||||
|
{!header.column.getIsSorted() && <span>⇅</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{isEmpty ? (
|
||||||
<tr key={row.id} className="bg-primary-100 odd:bg-white">
|
<tr>
|
||||||
{row.getVisibleCells().map((cell, index) => (
|
<td
|
||||||
<td
|
colSpan={t.getAllColumns().length}
|
||||||
key={cell.id}
|
className="py-8 px-5 text-center text-neutral-500"
|
||||||
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
|
>
|
||||||
>
|
No data available
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</td>
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
t.getRowModel().rows.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-primary-50 transition-colors',
|
||||||
|
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className={cn(
|
||||||
|
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
|
||||||
|
cell?.column?.columnDef?.meta?.cellClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination table={table} />
|
<Pagination table={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,11 +49,13 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^12.9.2",
|
"framer-motion": "^12.9.2",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "~16.0.3",
|
"next": "~16.0.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openapi-fetch": "^0.15.0",
|
"openapi-fetch": "^0.15.0",
|
||||||
"openapi-react-query": "^0.5.0",
|
"openapi-react-query": "^0.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user