Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dashboard

This commit is contained in:
randy-ar
2026-01-28 17:56:35 +07:00
53 changed files with 2269 additions and 2521 deletions
+203
View File
@@ -0,0 +1,203 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import { BaseApproval } from '@/types/api/api-general';
import Button from '@/components/Button';
import { cn, formatDate } from '@/lib/helper';
interface ApprovalStepsV2Props {
approvals?: BaseApproval[];
steps: {
step_number: number;
step_name: string;
}[];
maxVisibleSteps?: number;
className?: {
wrapper?: string;
stepsWrapper?: string;
stepsContainer?: string;
};
}
const ApprovalStepsV2 = ({
approvals,
steps,
maxVisibleSteps = 2,
className,
}: ApprovalStepsV2Props) => {
const [isSeeAll, setIsSeeAll] = useState(false);
const [formattedApprovals, setFormattedApprovals] = useState<
(BaseApproval & { isActive: boolean })[]
>([]);
const latestApprovalStepNumber =
approvals?.[approvals.length - 1].step_number ?? 0;
const lastStepNumber = steps[steps.length - 1].step_number;
const isLatestApprovalStepNumberLessThanLastStepNumber =
latestApprovalStepNumber < lastStepNumber;
const slicedFormattedApprovals = useMemo(() => {
return formattedApprovals.slice(0, isSeeAll ? undefined : maxVisibleSteps);
}, [formattedApprovals, isSeeAll]);
const seeMoreClickHandler = () => {
setIsSeeAll((prevVal) => !prevVal);
};
useEffect(() => {
if (approvals) {
const tempFormattedApprovals: (BaseApproval & { isActive: boolean })[] =
[];
approvals.forEach((approval) => {
tempFormattedApprovals.push({
...approval,
isActive: true,
});
});
if (isLatestApprovalStepNumberLessThanLastStepNumber) {
const latestApprovalStepNumberIndexInSteps = steps.findIndex(
(step) => step.step_number === latestApprovalStepNumber
);
const slicedSteps = steps.slice(
latestApprovalStepNumberIndexInSteps + 1
);
slicedSteps.forEach((step) => {
tempFormattedApprovals.push({
action: 'APPROVED',
action_at: new Date().toISOString(),
action_by: {
id: 0,
id_user: 0,
email: '',
name: '',
},
step_name: step.step_name,
step_number: step.step_number,
isActive: false,
});
});
}
setFormattedApprovals(tempFormattedApprovals);
}
}, [approvals]);
return (
<div
className={cn(
'w-full p-4 flex flex-col border-b border-base-content/10',
className?.wrapper
)}
>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Progress Details
</h4>
<div
className={cn(
'mt-6 mb-8 flex flex-col gap-10',
className?.stepsWrapper
)}
>
{slicedFormattedApprovals.map((approval, idx) => {
const isApprovalActionCreated = approval.action === 'CREATED';
const isApprovalActionUpdated = approval.action === 'UPDATED';
const isApprovalActionRejected = approval.action === 'REJECTED';
const isApprovalActionApproved = approval.action === 'APPROVED';
const approvalIcon =
isApprovalActionCreated || isApprovalActionUpdated
? 'heroicons:clock-solid'
: isApprovalActionRejected
? 'heroicons:x-circle-solid'
: isApprovalActionApproved
? 'heroicons:check-badge-solid'
: 'heroicons:check-badge-solid';
return (
<div key={idx} className='w-full flex flex-row items-stretch gap-3'>
<div className='w-fit self-stretch relative'>
<div className='w-fit h-fit flex flex-col items-start'>
<Icon
icon={approvalIcon}
width={24}
height={24}
className={cn({
'text-warning':
isApprovalActionCreated || isApprovalActionUpdated,
'text-error': isApprovalActionRejected,
'text-success': isApprovalActionApproved,
'text-base-content/20': !approval.isActive,
})}
/>
{idx < formattedApprovals.length - 1 && (
<div className='absolute top-6 left-1/2 -translate-x-1/2 w-0 min-h-full h-[calc(100%)] mx-auto my-2 border border-dashed border-base-content/10' />
)}
</div>
</div>
<div
className={cn('w-full flex flex-col gap-1 text-base-content', {
'text-base-content/20': !approval.isActive,
})}
>
<div className='flex flex-col'>
<span className='text-xs'>{approval.step_name}</span>
<span className='text-sm font-semibold'>
{(isApprovalActionCreated || isApprovalActionUpdated) &&
'Diajukan oleh '}
{isApprovalActionRejected && 'Ditolak oleh '}
{isApprovalActionApproved && 'Disetujui oleh '}
{approval.isActive ? approval.action_by.name : '...'}
</span>
</div>
{approval.isActive && (
<p className='w-full max-w-60 p-3 bg-base-content/5 rounded-xl text-xs text-base-content/50'>
Created at :{' '}
{formatDate(approval.action_at, 'DD-MM-YYYY, HH:mm')}
<br />
Notes : {approval.notes ?? '-'}
</p>
)}
</div>
</div>
);
})}
</div>
{formattedApprovals.length > maxVisibleSteps && (
<Button
variant='outline'
color='none'
onClick={seeMoreClickHandler}
className={cn(
'px-3 py-2 gap-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-lg transition-all'
)}
>
<Icon
icon='heroicons-outline:chevron-double-down'
width={20}
height={20}
className={cn('transition-all duration-300', {
'-rotate-180': isSeeAll,
})}
/>
See {isSeeAll ? 'Less' : 'More'}
</Button>
)}
</div>
);
};
export default ApprovalStepsV2;
@@ -0,0 +1,45 @@
'use client';
import { View, StyleSheet } from '@react-pdf/renderer';
import { PdfThead, PdfColumn } from './PdfThead';
import { PdfTbody, PdfTbodyCell } from './PdfTbody';
import { PdfTfoot, PdfTfootCell } from './PdfTfoot';
const styles = StyleSheet.create({
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
});
interface PdfTableProps {
columns: PdfColumn[];
data: PdfTbodyCell[][];
footer?: PdfTfootCell[];
footerLabel?: string;
firstRow?: {
valueKey: string;
value: number;
align?: 'right';
color?: string;
};
}
export const PdfTable = ({
columns,
data,
footer,
footerLabel = 'Total',
firstRow,
}: PdfTableProps) => {
return (
<View style={styles.table}>
<PdfThead columns={columns} />
<PdfTbody columns={columns} rows={data} firstRow={firstRow} />
{footer && footer.length > 0 && (
<PdfTfoot columns={columns} cells={footer} label={footerLabel} />
)}
</View>
);
};
@@ -0,0 +1,219 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTbodyCell {
key: string;
value: string | number | React.ReactNode;
align?: 'left' | 'center' | 'right';
color?: string;
formatAs?: 'text' | 'date' | 'currency' | 'number';
formatDate?: string;
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
borderRightWidth: 0,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
});
interface PdfTbodyProps {
columns: PdfColumn[];
rows: PdfTbodyCell[][];
firstRow?: {
valueKey: string;
value: number;
align?: 'right';
color?: string;
};
formatDate?: (date: string, format: string) => string;
formatNumber?: (num: number) => string;
formatCurrency?: (num: number) => string;
}
export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
return (
<>
{/* First Row */}
{firstRow && (
<View style={[styles.tableRow, styles.tableBorderBottom]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const isfirstRowColumn = column.key === firstRow.valueKey;
const align = column.align || 'center';
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
: isfirstRowColumn
? [
styles.tableCellRight,
{
flex: column.flex,
color: firstRow.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [
styles.tableCellLast,
{
flex: column.flex,
borderRightWidth: 0,
},
]
: [styles.tableCell, { flex: column.flex }];
return (
<View key={column.key} style={cellStyle}>
<Text>{isfirstRowColumn ? firstRow.value : ''}</Text>
</View>
);
})}
</View>
)}
{/* Data Rows */}
{rows.map((row, rowIndex) => {
const isLastRow = rowIndex === rows.length - 1;
return (
<View
key={rowIndex}
style={[
styles.tableRow,
!isLastRow ? styles.tableBorderBottom : {},
]}
>
{columns.map((column, colIndex) => {
const cell = row.find((c) => c.key === column.key);
const isLastColumn = colIndex === columns.length - 1;
const align = cell?.align || column.align || 'center';
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [
styles.tableCellLast,
{ flex: column.flex, borderRightWidth: 0 },
]
: [
styles.tableCell,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
];
return (
<View key={column.key} style={cellStyle}>
{cell?.value !== undefined &&
cell?.value !== null &&
cell?.value !== '' ? (
typeof cell.value === 'object' ? (
cell.value
) : (
<Text>{String(cell.value)}</Text>
)
) : (
<Text>-</Text>
)}
</View>
);
})}
</View>
);
})}
</>
);
};
@@ -0,0 +1,131 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTfootCell {
key: string;
value: string | number;
align?: 'left' | 'center' | 'right';
flex?: number;
color?: string;
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
summaryRow: {
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
borderRightWidth: 0,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
});
interface PdfTfootProps {
columns: PdfColumn[];
cells: PdfTfootCell[];
label?: string;
}
export const PdfTfoot = ({
columns,
cells,
label = 'Total',
}: PdfTfootProps) => {
return (
<View style={[styles.tableRow, styles.summaryRow]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const cellData = cells.find((c) => c.key === column.key);
const cellStyle =
column.key === 'no'
? [
styles.tableCellNo,
{ flex: column.flex, borderRightWidth: isLastColumn ? 0 : 1 },
]
: cellData?.align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cellData?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: cellData?.align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cellData?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [styles.tableCellLast, { flex: column.flex }]
: [
styles.tableCell,
{
flex: column.flex,
color: cellData?.color || 'black',
},
];
return (
<View key={column.key} style={cellStyle}>
<Text>{column.key === 'no' ? label : cellData?.value || ''}</Text>
</View>
);
})}
</View>
);
};
@@ -0,0 +1,89 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
});
interface PdfTheadProps {
columns: PdfColumn[];
}
export const PdfThead = ({ columns }: PdfTheadProps) => {
return (
<View style={[styles.tableRow, styles.tableHeader]}>
{columns.map((column, index) => {
const align = column.align || 'center';
const isLastColumn = index === columns.length - 1;
const cellStyle =
align === 'right'
? [
styles.tableCellHeaderRight,
{
flex: column.flex,
textAlign: 'right' as const,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: [
styles.tableCellHeader,
{
flex: column.flex,
textAlign: align as 'left' | 'center' | 'right',
borderRightWidth: isLastColumn ? 0 : 1,
},
];
return (
<View key={column.key} style={cellStyle}>
<Text>{column.header}</Text>
</View>
);
})}
</View>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { PdfTable } from './PdfTable';
export { PdfThead } from './PdfThead';
export { PdfTbody } from './PdfTbody';
export { PdfTfoot } from './PdfTfoot';
export type { PdfColumn } from './PdfThead';
export type { PdfTbodyCell } from './PdfTbody';
export type { PdfTfootCell } from './PdfTfoot';