Merge remote-tracking branch 'origin/development' into staging

This commit is contained in:
M1 AIR
2026-01-09 15:53:47 +07:00
128 changed files with 17554 additions and 1124 deletions
+2973 -23
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -12,26 +12,38 @@
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1", "@react-pdf/renderer": "^4.3.1",
"@supabase/supabase-js": "^2.89.0",
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lucide-react": "^0.562.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.9", "next": "15.5.9",
"react": "19.1.0", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.2",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "^19.1.2",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.70.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-resizable-panels": "2.1.7",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^2.0.7",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6", "use-debounce": "^10.0.6",
"vaul": "^1.1.2",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0", "yup": "^1.7.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
+7 -6
View File
@@ -19,10 +19,10 @@ const ClosingDetailPage = () => {
(id: number) => ClosingApi.getGeneralInfo(id) (id: number) => ClosingApi.getGeneralInfo(id)
); );
const { data: salesData, isLoading: isLoadingSales } = useSWR( // const { data: salesData, isLoading: isLoadingSales } = useSWR(
closingId ? `sales-${closingId}` : null, // closingId ? `sales-${closingId}` : null,
() => ClosingApi.getPenjualan(Number(closingId)) // () => ClosingApi.getPenjualan(Number(closingId))
); // );
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR( const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
closingId ? `hpp-ekspedisi-${closingId}` : null, closingId ? `hpp-ekspedisi-${closingId}` : null,
@@ -44,7 +44,8 @@ const ClosingDetailPage = () => {
return; return;
} }
const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi; const isLoading = isLoadingClosing || isLoadingHppEkspedisi;
// const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi;
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
@@ -54,7 +55,7 @@ const ClosingDetailPage = () => {
<ClosingDetail <ClosingDetail
id={Number(closingId)} id={Number(closingId)}
initialValue={closing.data} initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined} // salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
hppExpeditionData={ hppExpeditionData={
isResponseSuccess(hppEkspedisiData) isResponseSuccess(hppEkspedisiData)
? hppEkspedisiData.data ? hppEkspedisiData.data
@@ -0,0 +1,11 @@
import { DailyChecklistContent } from '@/figma-make/components/pages/daily-checklist/DailyChecklistContent';
const DailyChecklistPage = () => {
return (
<section className='w-full'>
<DailyChecklistContent />
</section>
);
};
export default DailyChecklistPage;
@@ -0,0 +1,11 @@
import { Dashboard as DashboardDailyChecklist } from '@/figma-make/components/pages/dashboard/Dashboard';
const DailyChecklistDashboardPage = () => {
return (
<section className='w-full'>
<DashboardDailyChecklist />
</section>
);
};
export default DailyChecklistDashboardPage;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,11 @@
import { DetailDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/detail/DetailDailyChecklistContent';
const ListDailyChecklistDetailPage = () => {
return (
<section className='w-full'>
<DetailDailyChecklistContent />
</section>
);
};
export default ListDailyChecklistDetailPage;
@@ -0,0 +1,11 @@
import { ListDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent';
const ListDailyChecklistPage = () => {
return (
<section className='w-full'>
<ListDailyChecklistContent />
</section>
);
};
export default ListDailyChecklistPage;
@@ -0,0 +1,11 @@
import { MasterAktivitasContent } from '@/figma-make/components/pages/master-data/activity/MasterAktivitasContent';
const MasterAktivitasPage = () => {
return (
<section className='w-full'>
<MasterAktivitasContent />
</section>
);
};
export default MasterAktivitasPage;
@@ -0,0 +1,11 @@
import { MasterEmployeeContent } from '@/figma-make/components/pages/master-data/employee/MasterEmployeeContent';
const MasterEmployeePage = () => {
return (
<section className='w-full'>
<MasterEmployeeContent />
</section>
);
};
export default MasterEmployeePage;
+11
View File
@@ -0,0 +1,11 @@
import { DailyChecklistReportsContent } from '@/figma-make/components/pages/reports/DailyChecklistReportsContent';
const DailyChecklistReportsPage = () => {
return (
<section className='w-full'>
<DailyChecklistReportsContent />
</section>
);
};
export default DailyChecklistReportsPage;
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin "daisyui"; @plugin "daisyui";
@import '../styles/daisyui.css'; @import '../styles/daisyui.css';
@import '../figma-make/styles/theme.css';
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: 'lti'; name: 'lti';
+2
View File
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google';
import '@/app/globals.css'; import '@/app/globals.css';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Toaster as SonnerToaster } from '@/figma-make/components/base/sonner';
import MainDrawer from '@/components/MainDrawer'; import MainDrawer from '@/components/MainDrawer';
import RequireAuth from '@/components/helper/RequireAuth'; import RequireAuth from '@/components/helper/RequireAuth';
@@ -35,6 +36,7 @@ export default function RootLayout({
</RequireAuth> </RequireAuth>
<Toaster /> <Toaster />
<SonnerToaster position='top-right' />
</body> </body>
</html> </html>
); );
+3 -1
View File
@@ -67,7 +67,9 @@ const MainDrawer = ({
const pathname = usePathname(); const pathname = usePathname();
const { permissionCheck } = useAuth(); const { permissionCheck } = useAuth();
const isPermitted = ROUTE_PERMISSIONS[pathname]?.some((permission) => const formattedPathname = pathname.endsWith('/') ? pathname : `${pathname}/`;
const isPermitted = ROUTE_PERMISSIONS[formattedPathname]?.some((permission) =>
permissionCheck(permission) permissionCheck(permission)
); );
+46
View File
@@ -0,0 +1,46 @@
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
/**
* Alert Unique Error List
* @param formErrorList - Array of error messages
* @param onClose - Function to close the alert
*/
const AlertErrorList = ({
formErrorList,
onClose,
}: {
formErrorList: string[];
onClose: () => void;
}) => {
return (
<Alert color='error' className='flex flex-col gap-2 px-4 m-4'>
<div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} />
<span className='font-semibold'>
Terdapat {formErrorList.length} error pada form:
</span>
</div>
<Button
onClick={onClose}
variant='link'
className='ml-auto p-0 w-fit text-white'
color='none'
>
<Icon icon='material-symbols:close' width={24} height={24} />
</Button>
</div>
<ul className='list-disc list-inside pl-8 space-y-1 w-full'>
{formErrorList.map((error, index) => (
<li key={index} className='text-sm'>
{error}
</li>
))}
</ul>
</Alert>
);
};
export default AlertErrorList;
+8
View File
@@ -33,6 +33,7 @@ const FileInput = ({
isError, isError,
errorMessage, errorMessage,
disabled = false, disabled = false,
required = false,
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
@@ -56,6 +57,13 @@ const FileInput = ({
)} )}
> >
{label} {label}
{required && (
<>
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label> </label>
)} )}
+1 -1
View File
@@ -309,7 +309,7 @@ const useApprovalSteps = ({
moduleId: string; moduleId: string;
params?: { params?: {
page?: number; page?: number;
limit: number; limit: number | string;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
}; };
+11 -6
View File
@@ -45,13 +45,18 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{ {
id: 'perhitunganSapronak', id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak', label: 'Perhitungan Sapronak',
content: <ClosingSapronakCalculationTabContent projectFlockId={id} />, content: (
}, <ClosingSapronakCalculationTabContent
{ closingGeneralInformation={initialValue}
id: 'penjualan', projectFlockId={id}
label: 'Penjualan', />
content: <SalesReportTable initialValues={salesData} />, ),
}, },
// {
// id: 'penjualan',
// label: 'Penjualan',
// content: <SalesReportTable initialValues={salesData} />,
// },
{ {
id: 'overhead', id: 'overhead',
label: 'Overhead', label: 'Overhead',
@@ -23,6 +23,14 @@ type HppTableRow =
type?: never; type?: never;
budgeting?: never; budgeting?: never;
realization?: never; realization?: never;
}
| {
type: string;
group_name: string;
group_index: number;
isGroupHeader: false;
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
}; };
type ProfitLossTableRow = type ProfitLossTableRow =
@@ -52,25 +60,117 @@ const ClosingFinanceTable = ({
() => ClosingApi.getFinance(projectFlockId) () => ClosingApi.getFinance(projectFlockId)
); );
const hppTableData: HppTableRow[] = isResponseSuccess(finance) const staticHppRows: Array<{
? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [ group_name: string;
// Group header row type: string;
{ group_index: number;
group_name: hpp.group_name, }> = [
group_index: groupIndex, {
isGroupHeader: true as const, group_name: 'HPP dan Pengeluaran',
}, type: 'Pembelian PAKAN',
// Data rows group_index: 0,
...hpp.data.map((item) => ({ },
group_name: hpp.group_name, {
group_index: groupIndex, group_name: 'HPP dan Pengeluaran',
type: item.type, type: 'Pembelian STARTER',
budgeting: item.budgeting, group_index: 0,
realization: item.realization, },
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian DOC',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PULLET',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian LAYER',
group_index: 0,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Pengeluaran Overhead',
group_index: 1,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Beban Ekspedisi',
group_index: 1,
},
];
const hppTableData: HppTableRow[] = [
{
group_name: 'HPP dan Pengeluaran',
group_index: 0,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 0)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const, isGroupHeader: false as const,
})), };
]) }),
: []; {
group_name: 'HPP dan Bahan Baku',
group_index: 1,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 1)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP',
group_index: 2,
isGroupHeader: true as const,
},
];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance) const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [ ? [
@@ -217,8 +317,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' && return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting finance.data.hpp_purchases.summary_hpp?.budgeting
.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -233,8 +333,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' && return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting finance.data.hpp_purchases.summary_hpp?.budgeting
.rp_per_kg || 0 ?.rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -249,8 +349,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' && return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.budgeting finance.data.hpp_purchases.summary_hpp?.budgeting
.amount || 0 ?.amount || 0
) )
: '-'; : '-';
}, },
@@ -271,8 +371,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' && return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization finance.data.hpp_purchases.summary_hpp
.rp_per_bird || 0 ?.realization?.rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -287,8 +387,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' && return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization finance.data.hpp_purchases.summary_hpp
.rp_per_kg || 0 ?.realization?.rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -303,8 +403,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' && return props.column.id === 'realization_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp.realization finance.data.hpp_purchases.summary_hpp
.amount || 0 ?.realization?.amount || 0
) )
: '-'; : '-';
}, },
@@ -1,21 +1,25 @@
'use client'; 'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable'; import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingSapronakCalculationTabContentProps { interface ClosingSapronakCalculationTabContentProps {
projectFlockId?: number; projectFlockId?: number;
closingGeneralInformation?: ClosingGeneralInformation;
} }
const ClosingSapronakCalculationTabContent = ({ const ClosingSapronakCalculationTabContent = ({
projectFlockId, projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTabContentProps) => { }: ClosingSapronakCalculationTabContentProps) => {
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{projectFlockId && ( {projectFlockId && (
<> <>
<ClosingSapronakCalculationTable projectFlockId={projectFlockId} /> <ClosingSapronakCalculationTable
closingGeneralInformation={closingGeneralInformation}
projectFlockId={projectFlockId}
/>
</> </>
)} )}
</div> </div>
@@ -13,15 +13,16 @@ import { useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingSapronakCalculationTableProps { interface ClosingSapronakCalculationTableProps {
type?: 'detail';
projectFlockId: number; projectFlockId: number;
closingGeneralInformation?: ClosingGeneralInformation;
} }
const ClosingSapronakCalculationTable = ({ const ClosingSapronakCalculationTable = ({
type,
projectFlockId, projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTableProps) => { }: ClosingSapronakCalculationTableProps) => {
const { data: sapronakCalculation, isLoading } = useSWR( const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}`, `/closing/sapronak-calculation/${projectFlockId}`,
@@ -182,8 +183,13 @@ const ClosingSapronakCalculationTable = ({
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Table DOC jika kategori Project Flock Growing */}
<Card <Card
title='DOC' title={
closingGeneralInformation?.project_category === 'GROWING'
? 'DOC'
: 'Pullet'
}
collapsible collapsible
defaultCollapsed={false} defaultCollapsed={false}
className={{ className={{
@@ -194,10 +200,16 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation> <Table<RowSapronakCalculation>
data={ data={
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc?.rows ?? []) ? ((closingGeneralInformation?.project_category === 'GROWING'
? sapronakCalculation.data?.doc?.rows
: sapronakCalculation.data?.pullet?.rows) ?? [])
: [] : []
} }
columns={docColumns} columns={
closingGeneralInformation?.project_category === 'GROWING'
? docColumns
: pulletColumns
}
className={{ className={{
containerClassName: 'my-4', containerClassName: 'my-4',
}} }}
@@ -250,29 +262,6 @@ const ClosingSapronakCalculationTable = ({
renderFooter={isResponseSuccess(sapronakCalculation)} renderFooter={isResponseSuccess(sapronakCalculation)}
/> />
</Card> </Card>
<Card
title='Pullet'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.pullet?.rows ?? [])
: []
}
columns={pulletColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter={isResponseSuccess(sapronakCalculation)}
/>
</Card>
</div> </div>
); );
}; };
@@ -140,17 +140,17 @@ const ExpenseRequestContent = ({
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
try { const deleteResponse = await ExpenseApi.delete(initialValues?.id as number);
await ExpenseApi.delete(initialValues?.id as number);
if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!'); toast.success('Berhasil menghapus data biaya operasional!');
router.push('/expense'); router.push('/expense');
} catch (error) { } else {
toast.error('Gagal menghapus data biaya operasional!'); toast.error('Gagal menghapus data biaya operasional!');
} finally {
deleteModal.closeModal();
setIsDeleteLoading(false);
} }
deleteModal.closeModal();
setIsDeleteLoading(false);
}; };
const confirmationModalCompleteClickHandler = async () => { const confirmationModalCompleteClickHandler = async () => {
@@ -21,7 +21,7 @@ const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
switch (latestApprovalStepNumber) { switch (latestApprovalStepNumber) {
case 1: case 1:
expenseStatusPillBadgeColor = 'yellow'; expenseStatusPillBadgeColor = 'gray';
break; break;
case 2: case 2:
@@ -33,7 +33,7 @@ const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
break; break;
case 4: case 4:
expenseStatusPillBadgeColor = 'red'; expenseStatusPillBadgeColor = 'yellow';
break; break;
case 5: case 5:
+12 -4
View File
@@ -420,11 +420,19 @@ const ExpensesTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ExpenseApi.delete(selectedExpense?.id as number); const deleteResponse = await ExpenseApi.delete(
refreshExpenses(); selectedExpense?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshExpenses();
deleteModal.closeModal();
toast.success('Berhasil menghapus biaya operasional!');
} else {
deleteModal.closeModal();
toast.error('Gagal menghapus biaya operasional!');
}
deleteModal.closeModal();
toast.success('Berhasil menghapus biaya operasional!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
@@ -125,6 +125,7 @@ const InventoryAdjustmentForm = ({
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '', search: '',
limit: '100',
}).toString()}`; }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl, warehouseUrl,
@@ -85,7 +85,10 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'), product_id: Yup.number()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
product_qty: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -97,7 +100,10 @@ const DeliveryProductObjectSchema = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'), product_id: Yup.number()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
product_qty: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -127,13 +133,13 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
(delivery_cost !== undefined && delivery_cost > 0) (delivery_cost !== undefined && delivery_cost > 0)
); );
}), }),
document_path: Yup.string().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: Yup.mixed<File | MovementDocument>()
.nullable() .nullable()
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => { .test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true; if (!value) return true;
if (value instanceof File) return value.size <= 2 * 1024 * 1024; if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true; return true;
}), }),
driver_name: Yup.string().required('Nama sopir wajib diisi!'), driver_name: Yup.string().required('Nama sopir wajib diisi!'),
@@ -142,7 +148,10 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
supplier_id: Yup.number().required('Supplier wajib diisi!'), supplier_id: Yup.number()
.required('Supplier wajib diisi!')
.min(1, 'Supplier wajib diisi!')
.typeError('Supplier wajib diisi!'),
products: Yup.array() products: Yup.array()
.of(DeliveryProductObjectSchema) .of(DeliveryProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
@@ -161,6 +170,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
source_warehouse_id: Yup.number() source_warehouse_id: Yup.number()
.required('Gudang asal wajib diisi!') .required('Gudang asal wajib diisi!')
.min(1, 'Gudang asal wajib diisi!')
.typeError('Gudang asal wajib diisi!'), .typeError('Gudang asal wajib diisi!'),
destination_warehouse: Yup.object({ destination_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -170,6 +180,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
destination_warehouse_id: Yup.number() destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.min(1, 'Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!')
.test( .test(
'different-warehouse', 'different-warehouse',
@@ -226,41 +237,62 @@ export const getMovementFormInitialValues = (
} }
: null, : null,
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
products: products: initialValues?.details?.map((detail) => ({
initialValues?.details?.map((detail) => ({ product: {
product: { value: detail.product.id,
value: detail.product.id, label: detail.product.name,
label: detail.product.name, },
}, product_id: detail.product.id,
product_id: detail.product.id, product_qty: detail.quantity,
product_qty: detail.quantity, })) ?? [
})) ?? [], {
deliveries: product: null,
initialValues?.deliveries?.map((d) => ({ product_id: 0,
delivery_cost: d.shipping_cost_total ?? undefined, product_qty: '',
delivery_cost_per_item: d.shipping_cost_item ?? undefined, },
document_number: d.document_number ?? '', ],
document: d.document ?? null, deliveries: initialValues?.deliveries?.map((d) => ({
document_path: d.document_path ?? null, delivery_cost: d.shipping_cost_total ?? undefined,
driver_name: d.driver_name ?? '', delivery_cost_per_item: d.shipping_cost_item ?? undefined,
vehicle_plate: d.vehicle_plate ?? '', document: d.document ?? null,
supplier: d.supplier document_path: d.document_path ?? null,
? { value: d.supplier.id, label: d.supplier.name } driver_name: d.driver_name ?? '',
: null, vehicle_plate: d.vehicle_plate ?? '',
supplier_id: d.supplier?.id ?? 0, supplier: d.supplier
products: ? { value: d.supplier.id, label: d.supplier.name }
d.items?.map((item) => { : null,
const productData = detailIdToProductId.get( supplier_id: d.supplier?.id ?? 0,
item.stock_transfer_detail_id products:
); d.items?.map((item) => {
return { const productData = detailIdToProductId.get(
product: productData item.stock_transfer_detail_id
? { value: productData.id, label: productData.name } );
: null, return {
product_id: productData?.id ?? 0, product: productData
product_qty: item.quantity, ? { value: productData.id, label: productData.name }
}; : null,
}) ?? [], product_id: productData?.id ?? 0,
})) ?? [], product_qty: item.quantity,
};
}) ?? [],
})) ?? [
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
document_path: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
},
],
}; };
}; };
@@ -36,6 +36,8 @@ import CheckboxInput from '@/components/input/CheckboxInput';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -53,6 +55,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
] = useState(''); ] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
@@ -186,12 +189,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return; return;
} }
const documents: File[] = []; const documents: File[] = [];
const documentNameToIndex = new Map<string, number>();
let sequentialDocumentIndex = 0;
const deliveriesPayload = values.deliveries.map((d) => { const deliveriesPayload = values.deliveries.map((d) => {
let documentIndex = 0; let documentIndex = -1;
if (d.document && d.document instanceof File) { if (d.document && d.document instanceof File) {
documents.push(d.document); const fileName = d.document.name;
documentIndex = documents.length - 1;
if (documentNameToIndex.has(fileName)) {
documentIndex = documentNameToIndex.get(fileName)!;
} else {
documents.push(d.document);
documentIndex = sequentialDocumentIndex;
documentNameToIndex.set(fileName, documentIndex);
sequentialDocumentIndex++;
}
} else if (d.document_path) {
const pathFileName =
d.document_path.split('/').pop() || d.document_path;
if (documentNameToIndex.has(pathFileName)) {
documentIndex = documentNameToIndex.get(pathFileName)!;
} else {
documentIndex = sequentialDocumentIndex;
documentNameToIndex.set(pathFileName, documentIndex);
sequentialDocumentIndex++;
}
} else if (d.document && !(d.document instanceof File)) {
const existingDocFileName =
d.document.path.split('/').pop() || d.document.path;
if (documentNameToIndex.has(existingDocFileName)) {
documentIndex = documentNameToIndex.get(existingDocFileName)!;
} else {
documentIndex = sequentialDocumentIndex;
documentNameToIndex.set(existingDocFileName, documentIndex);
sequentialDocumentIndex++;
}
} }
return { return {
@@ -199,7 +235,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
delivery_cost_per_item: delivery_cost_per_item:
parseInt((d.delivery_cost_per_item || '').toString()) || 0, parseInt((d.delivery_cost_per_item || '').toString()) || 0,
document_index: documentIndex, document_index: documentIndex,
document_path: d.document_path,
driver_name: d.driver_name, driver_name: d.driver_name,
vehicle_plate: d.vehicle_plate, vehicle_plate: d.vehicle_plate,
supplier_id: d.supplier_id, supplier_id: d.supplier_id,
@@ -761,8 +796,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type !== 'edit' && type !== 'edit' &&
type !== 'detail' type !== 'detail'
) { ) {
formik.setFieldValue('products', []); if (formik.values.products.length === 0) {
formik.setFieldValue('deliveries', []); formik.setFieldValue('products', [
{
product: null,
product_id: 0,
product_qty: '',
},
]);
}
if (formik.values.deliveries.length === 0) {
formik.setFieldValue('deliveries', [
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
document_path: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
},
]);
}
} }
}, [formik.values.source_warehouse_id]); }, [formik.values.source_warehouse_id]);
@@ -791,6 +854,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.errors.destination_warehouse_id, formik.errors.destination_warehouse_id,
]); ]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -810,10 +889,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Top card - Movement details */} {/* Top card - Movement details */}
<Card <Card
title='Detail Movement' title='Detail Movement'
@@ -1097,7 +1195,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1106,7 +1204,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1119,7 +1217,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.products?.map((product, idx) => ( {formik.values.products?.map((product, idx) => (
<tr key={`product-row-${idx}-${product.product_id}`}> <tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`product-${idx}`} name={`product-${idx}`}
checked={selectedProducts.includes(idx)} checked={selectedProducts.includes(idx)}
@@ -1311,7 +1409,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1320,7 +1418,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1329,7 +1427,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Supplier Supplier
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1338,7 +1436,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Plat Nomor Plat Nomor
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1348,7 +1446,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Pengiriman (Rp.) Biaya Pengiriman (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1357,7 +1455,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Per Item (Rp.) Biaya Per Item (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1366,7 +1464,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Nama Sopir Nama Sopir
<span <span
className='tooltip tooltip-error tooltip-bottom z-[9999]' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1379,7 +1477,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.deliveries?.map((delivery, idx) => ( {formik.values.deliveries?.map((delivery, idx) => (
<tr key={`delivery-row-${idx}`}> <tr key={`delivery-row-${idx}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`delivery-${idx}`} name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)} checked={selectedDeliveries.includes(idx)}
@@ -1562,7 +1660,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
width={20} width={20}
height={20} height={20}
/> />
{delivery.document.name} <span className='truncate max-w-[200px]'>
{delivery.document.name}
</span>
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -1582,12 +1682,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</> </>
) : ( ) : (
<FileInput <FileInput
accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > 2 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 2 MB!'); toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = ''; e.target.value = '';
return; return;
} }
@@ -1744,7 +1845,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
disabled={ disabled={
hasInvalidQty || hasInvalidQty ||
hasExceededStock || hasExceededStock ||
!formik.isValid ||
formik.isSubmitting || formik.isSubmitting ||
(formik.values.source_warehouse_id === (formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id && formik.values.destination_warehouse_id &&
@@ -1757,17 +1857,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
)} )}
</div> </div>
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
</> </>
@@ -682,7 +682,7 @@ const MarketingTable = () => {
<Modal <Modal
ref={productsModal.ref} ref={productsModal.ref}
className={{ className={{
modalBox: 'max-w-2/5 z-100', modalBox: 'xs:max-w-2/5 z-100',
}} }}
closeOnBackdrop closeOnBackdrop
> >
@@ -724,6 +724,7 @@ const MarketingTable = () => {
}, },
]} ]}
className={{ className={{
containerClassName: 'p-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
@@ -124,7 +124,10 @@ const MarketingDetail = ({
return ( return (
<> <>
<div className='flex flex-col w-full gap-4'> <div className='flex flex-col w-full gap-4'>
<FormHeader title='Detail Sales Order' backUrl='/marketing' /> <FormHeader
title={`Detail ${Number(initialValues?.latest_approval?.step_number) > 2 ? 'Delivery Order' : 'Sales Order'}`}
backUrl='/marketing'
/>
{!isLoadingApproval && approvals && ( {!isLoadingApproval && approvals && (
<ApprovalSteps approvals={approvals} /> <ApprovalSteps approvals={approvals} />
)} )}
@@ -202,8 +205,23 @@ const MarketingDetail = ({
No. Sales Order No. Sales Order
</td> </td>
<td>:</td> <td>:</td>
<td width='50%'>{initialValues?.so_number}</td> <td width='50%' className='font-mono'>
{initialValues?.so_number}
</td>
</tr> </tr>
{Number(initialValues?.latest_approval?.step_number) > 2 && (
<tr>
<td width='45%' className='font-semibold'>
No. Delivery Order
</td>
<td>:</td>
<td width='50%' className='font-mono'>
{initialValues?.delivery_order
?.map((item) => item.do_number)
.join(', ')}
</td>
</tr>
)}
<tr> <tr>
<td className='font-semibold'>Nama Pelanggan</td> <td className='font-semibold'>Nama Pelanggan</td>
<td>:</td> <td>:</td>
@@ -230,12 +248,27 @@ const MarketingDetail = ({
<td>{initialValues?.notes ?? '-'}</td> <td>{initialValues?.notes ?? '-'}</td>
</tr> </tr>
<tr> <tr>
<td className='font-semibold'>Dokumen</td> <td className='font-semibold'>Dokumen Penjualan</td>
<td>:</td> <td>:</td>
<td> <td>
<SalesOrderExport data={initialValues} /> <SalesOrderExport data={initialValues} />
</td> </td>
</tr> </tr>
{Number(initialValues?.latest_approval?.step_number) > 2 && (
<tr>
<td className='font-semibold'>Dokumen Pengiriman</td>
<td>:</td>
<td className='flex flex-wrap gap-2'>
{initialValues?.delivery_order?.map((item, index) => (
<DeliveryOrderExport
key={index}
data={initialValues}
deliveryOrder={item}
/>
))}
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -48,6 +48,8 @@ import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -217,6 +219,7 @@ const MarketingForm = ({
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>( const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add' 'add'
); );
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [deliveryOrderValues, setDeliveryOrderValues] = useState< const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[] DeliveryOrderProductFormValues[]
>( >(
@@ -558,11 +561,28 @@ const MarketingForm = ({
); );
}, [memoSalesOrder]); }, [memoSalesOrder]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit();
};
return ( return (
<> <>
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4'
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
<FormHeader <FormHeader
@@ -666,6 +686,14 @@ const MarketingForm = ({
</div> </div>
</div> </div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Form Actions */} {/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'> <div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}> <Button type='reset' color='warning' disabled={formik.isSubmitting}>
@@ -673,7 +701,7 @@ const MarketingForm = ({
</Button> </Button>
<Button <Button
type='submit' type='submit'
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
> >
Submit Submit
@@ -15,6 +15,9 @@ import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm'; import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -39,6 +42,8 @@ const DeliveryOrderProductForm = ({
null null
); );
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const salesOrder = salesOrders.find( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -163,15 +168,27 @@ const DeliveryOrderProductForm = ({
} }
}, [initialValues]); }, [initialValues]);
const handleValidateForm = () => {
formik.validateForm();
const formErrorList = getUniqueFormikErrors(formik.errors);
setFormErrorList(formErrorList);
if (formErrorList.length > 0) {
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleBlurField(currentInput);
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={(e) => { onSubmit={handleFormSubmit}
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formikErrorMessage && ( {formikErrorMessage && (
@@ -208,7 +225,7 @@ const DeliveryOrderProductForm = ({
...formik.values, ...formik.values,
marketing_product_id: undefined, marketing_product_id: undefined,
marketing_product: null, marketing_product: null,
qty: formik.values.qty || '', qty: '',
unit_price: '', unit_price: '',
total_price: '', total_price: '',
avg_weight: '', avg_weight: '',
@@ -222,7 +239,7 @@ const DeliveryOrderProductForm = ({
...formik.values, ...formik.values,
marketing_product_id: selected.value as number, marketing_product_id: selected.value as number,
marketing_product: SalesProductToFieldValues(so), marketing_product: SalesProductToFieldValues(so),
qty: formik.values.qty || so.qty, qty: so.qty,
unit_price: so.unit_price, unit_price: so.unit_price,
total_price: so.total_price, total_price: so.total_price,
avg_weight: so.avg_weight, avg_weight: so.avg_weight,
@@ -298,8 +315,18 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)} isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
bottomLabel={
formik.values.marketing_product_id
? 'Stok dijual: ' +
salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id
)?.qty
: ''
}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -361,6 +388,13 @@ const DeliveryOrderProductForm = ({
/> />
</div> </div>
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'> <Button type='reset' color='warning'>
Reset Reset
@@ -368,7 +402,7 @@ const DeliveryOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -25,15 +25,19 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
id: Yup.number(), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'), vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
}).nullable(), }).nullable(),
kandang_id: Yup.number() kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!') .min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'), .required('Kandang wajib diisi!'),
product_warehouse: Yup.object({ product_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
label: Yup.string().required('Produk wajib diisi!'),
}).nullable(), }).nullable(),
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!') .min(1, 'Produk wajib diisi!')
@@ -17,9 +17,15 @@ import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatVechicleNumber } from '@/lib/helper'; import {
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput'; import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -33,7 +39,9 @@ const SalesOrderProductForm = ({
}) => { }) => {
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
@@ -58,6 +66,7 @@ const SalesOrderProductForm = ({
isInitialValid: false, isInitialValid: false,
}); });
// ===== Options =====
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
@@ -86,12 +95,13 @@ const SalesOrderProductForm = ({
); );
}, [warehouseSourceOptions, exisitingValues]); }, [warehouseSourceOptions, exisitingValues]);
// ===== Handler =====
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType); formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value); formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('qty', null); formik.setFieldValue('qty', '');
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -106,7 +116,7 @@ const SalesOrderProductForm = ({
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
formik.setFieldValue('qty', null); formik.setFieldValue('qty', '');
} }
}; };
@@ -162,15 +172,29 @@ const SalesOrderProductForm = ({
} }
}; };
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleBlurField(currentInput);
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={(e) => { onSubmit={handleFormSubmit}
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formErrorMessage && ( {formErrorMessage && (
@@ -248,7 +272,24 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
bottomLabel={
isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id
? `Stok tersedia: ${formatNumber(
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0
)} ${
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.product?.uom?.name ?? ''
}`
: ''
}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -314,6 +355,15 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Penjualan' placeholder='Masukan Total Penjualan'
/> />
</div> </div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}> <Button type='reset' color='warning' onClick={handleResetForm}>
Reset Reset
@@ -321,7 +371,7 @@ const SalesOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -11,6 +11,8 @@ import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductCategoryFormSchema, ProductCategoryFormSchema,
@@ -39,6 +41,7 @@ const ProductCategoryForm = ({
const deleteModal = useModal(); const deleteModal = useModal();
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductCategoryHandler = useCallback( const createProductCategoryHandler = useCallback(
@@ -129,6 +132,22 @@ const ProductCategoryForm = ({
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -150,10 +169,29 @@ const ProductCategoryForm = ({
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<TextInput <TextInput
required required
@@ -236,7 +274,7 @@ const ProductCategoryForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -244,17 +282,6 @@ const ProductCategoryForm = ({
</div> </div>
)} )}
</div> </div>
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
@@ -29,36 +29,38 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({ uom: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Satuan wajib dipilih!')
}) .required('Satuan wajib dipilih!'),
.nullable() label: Yup.string().required('Satuan wajib dipilih!'),
.required('Satuan wajib diisi!'), }).nullable(),
uom_id: Yup.number() uom_id: Yup.number()
.required('Satuan wajib diisi!') .min(1, 'Satuan wajib dipilih!')
.typeError('Satuan wajib diisi!'), .required('Satuan wajib dipilih!')
.typeError('Satuan wajib dipilih!'),
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Kategori produk wajib dipilih!')
}) .required('Kategori produk wajib dipilih!'),
.nullable() label: Yup.string().required('Kategori produk wajib dipilih!'),
.required('Kategori produk wajib diisi!'), }).nullable(),
product_category_id: Yup.number() product_category_id: Yup.number()
.required('Kategori produk wajib diisi!') .min(1, 'Kategori produk wajib dipilih!')
.typeError('Kategori produk wajib diisi!'), .required('Kategori produk wajib dipilih!')
.typeError('Kategori produk wajib dipilih!'),
product_price: Yup.number() product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!') .typeError('Harga produk wajib diisi!')
.min(0, 'Harga produk tidak boleh kurang dari 0!'), .min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!') .typeError('Harga jual wajib diisi!')
.min(0, 'Harga jual tidak boleh kurang dari 0!'), .min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
@@ -69,7 +71,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa wajib diisi!')
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), .min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array() supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(Yup.number().required().typeError('Supplier tidak valid!'))
@@ -17,6 +17,8 @@ import SelectInput, {
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductFormSchema, ProductFormSchema,
@@ -48,6 +50,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const deleteModal = useModal(); const deleteModal = useModal();
const [productFormErrorMessage, setProductFormErrorMessage] = useState(''); const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductHandler = useCallback( const createProductHandler = useCallback(
@@ -201,6 +204,22 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -220,11 +239,30 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
<div className='flex flex-col gap-4'> {productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='grid grid-cols-1 gap-4'>
<TextInput <TextInput
required required
label='Nama' label='Nama'
@@ -237,179 +275,193 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.name} errorMessage={formik.errors.name}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <div className='grid sm:grid-cols-2 gap-4'>
required <TextInput
label='Merek' required
name='brand' label='Merek'
placeholder='Masukkan merek...' name='brand'
value={formik.values.brand} placeholder='Masukkan merek...'
onChange={formik.handleChange} value={formik.values.brand}
onBlur={formik.handleBlur} onChange={formik.handleChange}
isError={formik.touched.brand && Boolean(formik.errors.brand)} onBlur={formik.handleBlur}
errorMessage={formik.errors.brand} isError={formik.touched.brand && Boolean(formik.errors.brand)}
readOnly={type === 'detail'} errorMessage={formik.errors.brand}
/> readOnly={type === 'detail'}
<TextInput />
required <TextInput
label='SKU' required
name='sku' label='SKU'
placeholder='Masukkan SKU...' name='sku'
value={formik.values.sku} placeholder='Masukkan SKU...'
onChange={formik.handleChange} value={formik.values.sku}
onBlur={formik.handleBlur} onChange={formik.handleChange}
isError={formik.touched.sku && Boolean(formik.errors.sku)} onBlur={formik.handleBlur}
errorMessage={formik.errors.sku} isError={formik.touched.sku && Boolean(formik.errors.sku)}
readOnly={type === 'detail'} errorMessage={formik.errors.sku}
/> readOnly={type === 'detail'}
<SelectInput />
required </div>
label='Satuan' <div className='grid sm:grid-cols-2 gap-4'>
placeholder='Pilih satuan...' <SelectInput
value={formik.values.uom ?? undefined} required
onChange={uomChangeHandler} label='Satuan'
options={uomOptions} placeholder='Pilih satuan...'
onInputChange={setUomSelectInputValue} value={formik.values.uom ?? undefined}
isLoading={isLoadingUoms} onChange={uomChangeHandler}
isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)} options={uomOptions}
errorMessage={formik.errors.uom_id as string} onInputChange={setUomSelectInputValue}
isDisabled={type === 'detail'} isLoading={isLoadingUoms}
isClearable isError={
/> (formik.touched.uom || formik.touched.uom_id) &&
<SelectInput Boolean(formik.errors.uom_id)
required }
label='Kategori Produk' errorMessage={formik.errors.uom_id as string}
placeholder='Pilih kategori produk...' isDisabled={type === 'detail'}
value={formik.values.product_category ?? undefined} isClearable
onChange={categoryChangeHandler} />
options={categoryOptions} <SelectInput
onInputChange={setCategorySelectInputValue} required
isLoading={isLoadingCategories} label='Kategori Produk'
isError={ placeholder='Pilih kategori produk...'
formik.touched.product_category_id && value={formik.values.product_category ?? undefined}
Boolean(formik.errors.product_category_id) onChange={categoryChangeHandler}
} options={categoryOptions}
errorMessage={formik.errors.product_category_id as string} onInputChange={setCategorySelectInputValue}
isDisabled={type === 'detail'} isLoading={isLoadingCategories}
isClearable isError={
/> (formik.touched.product_category ||
<NumberInput formik.touched.product_category_id) &&
required Boolean(formik.errors.product_category_id)
label='Harga Produk' }
name='product_price' errorMessage={formik.errors.product_category_id as string}
placeholder='Masukkan harga produk...' isDisabled={type === 'detail'}
value={formik.values.product_price} isClearable
onChange={formik.handleChange} />
onBlur={formik.handleBlur} </div>
decimalScale={2} <div className='grid sm:grid-cols-2 gap-4'>
allowNegative={false} <NumberInput
thousandSeparator=',' required
decimalSeparator='.' label='Harga Produk'
inputPrefix='Rp ' name='product_price'
isError={ placeholder='Masukkan harga produk...'
formik.touched.product_price && value={formik.values.product_price}
Boolean(formik.errors.product_price) onChange={formik.handleChange}
} onBlur={formik.handleBlur}
errorMessage={formik.errors.product_price as string} decimalScale={2}
readOnly={type === 'detail'} allowNegative={false}
/> thousandSeparator=','
<NumberInput decimalSeparator='.'
required inputPrefix='Rp '
label='Harga Jual' isError={
name='selling_price' formik.touched.product_price &&
placeholder='Masukkan harga jual...' Boolean(formik.errors.product_price)
value={formik.values.selling_price} }
onChange={formik.handleChange} errorMessage={formik.errors.product_price as string}
onBlur={formik.handleBlur} readOnly={type === 'detail'}
decimalScale={2} />
allowNegative={false} <NumberInput
thousandSeparator=',' required
decimalSeparator='.' label='Harga Jual'
inputPrefix='Rp ' name='selling_price'
isError={ placeholder='Masukkan harga jual...'
formik.touched.selling_price && value={formik.values.selling_price}
Boolean(formik.errors.selling_price) onChange={formik.handleChange}
} onBlur={formik.handleBlur}
errorMessage={formik.errors.selling_price as string} decimalScale={2}
readOnly={type === 'detail'} allowNegative={false}
/> thousandSeparator=','
<NumberInput decimalSeparator='.'
required inputPrefix='Rp '
label='Pajak (%)' isError={
name='tax' formik.touched.selling_price &&
placeholder='Masukkan pajak...' Boolean(formik.errors.selling_price)
value={formik.values.tax} }
onChange={formik.handleChange} errorMessage={formik.errors.selling_price as string}
onBlur={formik.handleBlur} readOnly={type === 'detail'}
decimalScale={2} />
allowNegative={false} </div>
thousandSeparator=',' <div className='grid sm:grid-cols-2 gap-4'>
decimalSeparator='.' <NumberInput
inputSuffix='%' required
isError={formik.touched.tax && Boolean(formik.errors.tax)} label='Pajak (%)'
errorMessage={formik.errors.tax as string} name='tax'
readOnly={type === 'detail'} placeholder='Masukkan pajak...'
/> value={formik.values.tax}
<NumberInput onChange={formik.handleChange}
required onBlur={formik.handleBlur}
label='Periode Kadaluarsa (hari)' decimalScale={2}
name='expiry_period' allowNegative={false}
placeholder='Masukkan periode kadaluarsa...' thousandSeparator=','
value={formik.values.expiry_period} decimalSeparator='.'
onChange={formik.handleChange} inputSuffix='%'
onBlur={formik.handleBlur} isError={formik.touched.tax && Boolean(formik.errors.tax)}
decimalScale={0} errorMessage={formik.errors.tax as string}
allowNegative={false} readOnly={type === 'detail'}
thousandSeparator=',' />
decimalSeparator='.' <NumberInput
inputSuffix='hari' required
isError={ label='Periode Kadaluarsa (hari)'
formik.touched.expiry_period && name='expiry_period'
Boolean(formik.errors.expiry_period) placeholder='Masukkan periode kadaluarsa...'
} value={formik.values.expiry_period}
errorMessage={formik.errors.expiry_period as string} onChange={formik.handleChange}
readOnly={type === 'detail'} onBlur={formik.handleBlur}
/> decimalScale={0}
<SelectInput allowNegative={false}
required thousandSeparator=','
label='Supplier' decimalSeparator='.'
placeholder='Pilih supplier...' inputSuffix='hari'
isMulti isError={
value={supplierOptions.filter((opt) => formik.touched.expiry_period &&
(formik.values.supplier_ids || []).includes(opt.value) Boolean(formik.errors.expiry_period)
)} }
onChange={supplierChangeHandler} errorMessage={formik.errors.expiry_period as string}
options={supplierOptions} readOnly={type === 'detail'}
onInputChange={setSupplierSelectInputValue} />
isLoading={isLoadingSuppliers} </div>
isError={ <div className='grid sm:grid-cols-2 gap-4'>
formik.touched.supplier_ids && <SelectInput
Boolean(formik.errors.supplier_ids) required
} label='Supplier'
errorMessage={formik.errors.supplier_ids as string} placeholder='Pilih supplier...'
isDisabled={type === 'detail'} isMulti
isClearable value={supplierOptions.filter((opt) =>
/> (formik.values.supplier_ids || []).includes(opt.value)
<SelectInput )}
required onChange={supplierChangeHandler}
label='Flags' options={supplierOptions}
placeholder='Pilih flags...' onInputChange={setSupplierSelectInputValue}
isMulti isLoading={isLoadingSuppliers}
value={PRODUCT_FLAG_OPTIONS.filter((opt) => isError={
(formik.values.flags || []).includes(opt.value) formik.touched.supplier_ids &&
)} Boolean(formik.errors.supplier_ids)
onChange={(val) => { }
const arr = Array.isArray(val) ? val : val ? [val] : []; errorMessage={formik.errors.supplier_ids as string}
formik.setFieldValue( isDisabled={type === 'detail'}
'flags', isClearable
arr.map((v) => (v as OptionType).value) />
); <SelectInput
}} required
options={PRODUCT_FLAG_OPTIONS} label='Flags'
isError={formik.touched.flags && Boolean(formik.errors.flags)} placeholder='Pilih flags...'
errorMessage={formik.errors.flags as string} isMulti
isDisabled={type === 'detail'} value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
isClearable (formik.values.flags || []).includes(opt.value)
/> )}
onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldValue(
'flags',
arr.map((v) => (v as OptionType).value)
);
}}
options={PRODUCT_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)}
errorMessage={formik.errors.flags as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
@@ -463,7 +515,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -471,16 +523,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
)} )}
</div> </div>
{productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
{type !== 'add' && ( {type !== 'add' && (
@@ -18,6 +18,7 @@ import { Icon } from '@iconify/react';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line'; import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { BaseApproval } from '@/types/api/api-general';
const ChickinFormKandang = ({ const ChickinFormKandang = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
@@ -33,11 +34,16 @@ const ChickinFormKandang = ({
approvals, approvals,
isLoading: approvalsLoading, isLoading: approvalsLoading,
refresh: refreshApprovals, refresh: refreshApprovals,
rawDataApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.chickin_approval,
approvalLines: CHICKINS_APPROVAL_LINE, approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'CHICKINS', moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '', moduleId: initialValues?.id.toString() ?? '',
params: {
limit: 'limit',
group_step_number: false,
},
}); });
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
@@ -180,6 +186,7 @@ const ChickinFormKandang = ({
</div> </div>
{openChickin && ( {openChickin && (
<ChickinLogsView <ChickinLogsView
rawDataApprovals={rawDataApprovals as BaseApproval[]}
initialValues={initialValues} initialValues={initialValues}
afterSubmit={afterSubmitFormChickin} afterSubmit={afterSubmitFormChickin}
/> />
@@ -8,6 +8,7 @@ import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin'; import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useState } from 'react'; import { useState } from 'react';
@@ -16,9 +17,11 @@ import toast from 'react-hot-toast';
const ChickinLogsView = ({ const ChickinLogsView = ({
initialValues, initialValues,
afterSubmit, afterSubmit,
rawDataApprovals,
}: { }: {
initialValues: ProjectFlockKandang; initialValues: ProjectFlockKandang;
afterSubmit?: () => void; afterSubmit?: () => void;
rawDataApprovals: BaseApproval[];
}) => { }) => {
const confirmModal = useModal(); const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -60,8 +63,15 @@ const ChickinLogsView = ({
</div> </div>
) : ( ) : (
(initialValues?.chickins || []).map((chickin, index) => { (initialValues?.chickins || []).map((chickin, index) => {
const isApproved = chickin.usage_qty !== 0; const latestApproval = rawDataApprovals[0];
const isPending = chickin.pending_usage_qty !== 0; const isApproved =
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 2
: true;
const isPending =
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 1
: false;
const quantity = isApproved const quantity = isApproved
? chickin.usage_qty ? chickin.usage_qty
: isPending : isPending
@@ -81,7 +91,7 @@ const ChickinLogsView = ({
{/* Header with Status Badge */} {/* Header with Status Badge */}
<div className='flex flex-row justify-between items-center'> <div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Chick In #{index + 1} Chick In #{index + 1} - {latestApproval?.step_number}
</div> </div>
<PillBadge <PillBadge
content={ content={
@@ -146,18 +156,19 @@ const ChickinLogsView = ({
}) })
)} )}
{initialValues?.approval?.step_number <= 2 && ( {initialValues.chickin_approval &&
<RequirePermission permissions='lti.production.chickins.approve'> initialValues?.chickin_approval?.step_number < 2 && (
<Button <RequirePermission permissions='lti.production.chickins.approve'>
color='success' <Button
onClick={handleClickApprove} color='success'
className='w-full' onClick={handleClickApprove}
> className='w-full'
<Icon width={24} height={24} icon='material-symbols:check' /> >
Approve Semua Chick In <Icon width={24} height={24} icon='material-symbols:check' />
</Button> Approve Semua Chick In
</RequirePermission> </Button>
)} </RequirePermission>
)}
{chickinErrorMessage && ( {chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}> <div className='w-full' onClick={() => setChickinErrorMessage('')}>
@@ -308,7 +308,10 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<Button <Button
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
href='/production/project-flock/add' onClick={() => {
setRowSelection({});
router.push('/production/project-flock/add');
}}
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
@@ -64,9 +64,9 @@ export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSche
.min(1, 'Harga minimal 1!') .min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'), .required('Harga wajib diisi!'),
total_price: Yup.number() total_price: Yup.number()
.typeError('Harga harus berupa angka!') .typeError('Total Harga harus berupa angka!')
.min(1, 'Harga minimal 1!') .min(1, 'Total Harga minimal 1!')
.required('Harga wajib diisi!'), .required('Total Harga wajib diisi!'),
}); });
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> = export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
@@ -6,6 +6,8 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
AreaApi, AreaApi,
FcrApi, FcrApi,
@@ -38,11 +40,6 @@ import { BaseApiResponse } from '@/types/api/api-general';
import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant'; import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import Card from '@/components/Card'; import Card from '@/components/Card';
import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable'; import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable';
@@ -69,8 +66,10 @@ const ProjectFlockForm = ({
const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] = const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState(''); const [selectedLocation, setSelectedLocation] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [disabledLocation, setDisabledLocation] = useState( const [disabledLocation, setDisabledLocation] = useState(
initialValues?.location?.id ? false : true initialValues?.location?.id ? false : true
); );
@@ -90,18 +89,8 @@ const ProjectFlockForm = ({
const setIsValid = useUiStore((s) => s.setIsValid); const setIsValid = useUiStore((s) => s.setIsValid);
const deleteModal = useModal(); const deleteModal = useModal();
const confirmModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
initialValues?.approval?.step_name == 'Pengajuan' ? false : true
);
const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>( const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
() => () =>
@@ -140,11 +129,15 @@ const ProjectFlockForm = ({
const { const {
options: optionsProductionStandards, options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name'); } = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory,
});
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '', search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation, location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit',
}).toString()}`; }).toString()}`;
const { const {
data: kandang, data: kandang,
@@ -163,17 +156,6 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingNonstocks, isLoadingOptions: isLoadingNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: initialValues?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: initialValues?.id.toString() ?? '',
});
useEffect(() => { useEffect(() => {
if (isResponseSuccess(kandang)) { if (isResponseSuccess(kandang)) {
if (selectedLocation) { if (selectedLocation) {
@@ -263,9 +245,19 @@ const ProjectFlockForm = ({
}; };
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('category', (val as OptionType)?.value); // Reset production standard when category is changed
formik.setFieldValue('production_standard_id', '');
formik.setFieldValue('production_standard', '');
formik.setFieldValue('category_option', val); formik.setFieldValue('category_option', val);
if (val == null) { formik.setFieldValue('category', val ? (val as OptionType)?.value : '');
setSelectedCategory((val as OptionType)?.value as string);
if (Boolean(val)) {
formik.setFieldTouched('category', false);
formik.setFieldError('category', '');
} else {
formik.setFieldTouched('category', true); formik.setFieldTouched('category', true);
} }
}; };
@@ -404,8 +396,6 @@ const ProjectFlockForm = ({
validationSchema: validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
validateOnBlur: true, validateOnBlur: true,
// validateOnChange: true,
// validateOnMount: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
@@ -522,19 +512,6 @@ const ProjectFlockForm = ({
return unsub; return unsub;
}, []); }, []);
useEffect(() => {
if (initialValues?.approval?.step_name) {
const pengajuanRejected =
initialValues.approval.step_number == 1 &&
initialValues.approval.action == 'REJECTED';
const approvedDisabled =
initialValues.approval.step_number !== 1 || pengajuanRejected;
setIsApprovedDisabled(approvedDisabled);
setIsRejectedDisabled(!approvedDisabled || pengajuanRejected);
setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED');
}
}, [initialValues]);
// Actions handler // Actions handler
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -588,29 +565,6 @@ const ProjectFlockForm = ({
} }
}; };
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'REJECTED' | 'APPROVED'
) => {
if (initialValues?.id === undefined) return;
setIsApproveLoading(true);
const approvalRes =
approvalAction == 'APPROVED'
? await ProjectFlockApi.approve(initialValues?.id, notes)
: await ProjectFlockApi.reject(initialValues?.id, notes);
if (isResponseSuccess(approvalRes)) {
refreshProjectFlocks?.();
toast.success(approvalRes.message as string);
}
if (isResponseError(approvalRes)) {
toast.error(approvalRes?.message as string);
}
refreshApprovals();
confirmModal.closeModal();
setIsApproveLoading(false);
};
const handleBudgetChange = ( const handleBudgetChange = (
index: number, index: number,
fieldName: 'qty' | 'price' | 'total_price', fieldName: 'qty' | 'price' | 'total_price',
@@ -688,6 +642,17 @@ const ProjectFlockForm = ({
return !isNonstockAlreadyInBudgets; return !isNonstockAlreadyInBudgets;
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -744,50 +709,14 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
)} )}
{approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} />
)}
{formType == 'detail' && (
<div className='w-full flex flex-col sm:flex-row gap-2 py-4'>
<RequirePermission permissions='lti.production.project_flocks.approve'>
<Button
variant='outline'
color='success'
onClick={() => {
if (initialValues?.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
}}
disabled={!initialValues?.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
</RequirePermission>
<RequirePermission permissions='lti.production.project_flocks.approve'>
<Button
variant='outline'
color='error'
onClick={() => {
if (initialValues?.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!initialValues?.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</RequirePermission>
</div>
)}
<form <form
className='w-auto h-auto' className='w-auto h-auto'
onSubmit={formik.handleSubmit} onSubmit={(e) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
}}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
{/* Form Informasi Umum */} {/* Form Informasi Umum */}
@@ -872,23 +801,6 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
<SelectInput
required
label='Standar Produksi'
value={formik.values.production_standard as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
formik.touched.production_standard &&
Boolean(formik.errors.production_standard)
}
errorMessage={formik.errors.production_standard as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput <SelectInput
required required
label='Kategori' label='Kategori'
@@ -902,6 +814,23 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
<SelectInput
required
label='Standar Produksi'
value={formik.values.production_standard as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
formik.touched.production_standard_id &&
Boolean(formik.errors.production_standard_id)
}
errorMessage={formik.errors.production_standard_id as string}
isClearable
isDisabled={formType != 'add'}
/>
<NumberInput <NumberInput
name='period' name='period'
label='Periode' label='Periode'
@@ -1153,15 +1082,15 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'> <div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'>
{/* <div className='w-120'>
<div className='text-primary text-sm'>
{JSON.stringify(formik.values)}
</div>
<div className='text-error text-sm'>
{JSON.stringify(formik.errors)}
</div>
</div> */}
{formType !== 'detail' && ( {formType !== 'detail' && (
<RequirePermission <RequirePermission
permissions={ permissions={
@@ -1174,7 +1103,7 @@ const ProjectFlockForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4 w-full' className='px-4 w-full'
> >
<Icon icon='mdi:plus' width={24} height={24} /> <Icon icon='mdi:plus' width={24} height={24} />
@@ -1200,27 +1129,6 @@ const ProjectFlockForm = ({
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
<ConfirmationModalWithNotes
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} Project Flock berikut? (${initialValues?.flock_name} - ${
initialValues?.area?.name
})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: (notes) => {
confirmApprovalHandler(notes, approvalAction);
},
}}
/>
</> </>
); );
}; };
@@ -17,6 +17,7 @@ import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -52,6 +53,7 @@ import {
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import ApprovalSteps, { import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
@@ -60,7 +62,6 @@ import {
GROWING_RECORDING_APPROVAL_LINE, GROWING_RECORDING_APPROVAL_LINE,
LAYING_RECORDING_APPROVAL_LINE, LAYING_RECORDING_APPROVAL_LINE,
} from '@/config/approval-line'; } from '@/config/approval-line';
import Table from '@/components/Table';
interface RecordingFormProps { interface RecordingFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -92,6 +93,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const [recordingFormErrorMessage, setRecordingFormErrorMessage] = const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setNewRecordingData] = useState<Recording | null>(null); const [, setNewRecordingData] = useState<Recording | null>(null);
const [nextDayRecording, setNextDayRecording] = const [nextDayRecording, setNextDayRecording] =
@@ -758,6 +760,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, },
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
useCallback((): OptionType | null => { useCallback((): OptionType | null => {
if ( if (
@@ -1323,9 +1341,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{recordingFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{recordingFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Basic Info Card */} {/* Basic Info Card */}
{(type === 'add' || type === 'edit') && ( {(type === 'add' || type === 'edit') && (
<Card <Card
@@ -2507,9 +2544,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={hasExceededStock || formik.isSubmitting}
hasExceededStock || !formik.isValid || formik.isSubmitting
}
> >
Submit Submit
</Button> </Button>
@@ -2534,9 +2569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={hasExceededStock || formik.isSubmitting}
hasExceededStock || !formik.isValid || formik.isSubmitting
}
> >
Submit Submit
</Button> </Button>
@@ -2544,16 +2577,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
</div> </div>
</div> </div>
{recordingFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{recordingFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
@@ -1,93 +1,104 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import Card from '@/components/Card'; import Card from '@/components/Card';
import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart'; import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart';
import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart'; import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart';
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton'; import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton'; import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
import { import { Uniformity, type ChartData } from '@/types/api/production/uniformity';
UniformityDetailItem,
Uniformity,
} from '@/types/api/production/uniformity';
interface UniformityChartProps { interface UniformityChartProps {
uniformityData?: Uniformity | null; uniformityData?: Uniformity | null;
uniformityDetails?: UniformityDetailItem[]; isFiltered?: boolean;
} }
const UniformityChart = ({ const UniformityChart = ({
uniformityData, uniformityData,
uniformityDetails, isFiltered = false,
}: UniformityChartProps) => { }: UniformityChartProps) => {
const defaultUniformityDetails: UniformityDetailItem[] = [ const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
{ id: 1, weight: 61, range: 'Ideal' },
{ id: 2, weight: 62, range: 'Ideal' },
{ id: 3, weight: 63, range: 'Ideal' },
{ id: 4, weight: 64, range: 'Ideal' },
{ id: 5, weight: 65, range: 'Ideal' },
{ id: 6, weight: 66, range: 'Ideal' },
{ id: 7, weight: 67, range: 'Ideal' },
];
const detailsToUse = uniformityDetails || defaultUniformityDetails; const chartData = useMemo((): ChartData | undefined => {
if (!uniformityData?.chart_data) return undefined;
return uniformityData.chart_data;
}, [uniformityData]);
useEffect(() => {
if (uniformityData?.chart_data?.gauge_chart?.week_info) {
const { current_week_index } =
uniformityData.chart_data.gauge_chart.week_info;
setCurrentWeekIndex(current_week_index);
}
}, [uniformityData]);
const barChartData = useMemo(() => { const barChartData = useMemo(() => {
if (!uniformityData) { if (!chartData?.bar_chart || !chartData?.gauge_chart) {
return []; return [];
} }
if (!detailsToUse || detailsToUse.length === 0) { const { bar_chart, gauge_chart } = chartData;
const currentWeekData = gauge_chart.available_weeks[currentWeekIndex];
if (!currentWeekData || !currentWeekData.has_data) {
return []; return [];
} }
const weights = detailsToUse.map((d) => d.weight); const currentWeekStr = String(currentWeekData.week);
const minWeight = Math.floor(Math.min(...weights) / 5) * 5; const weekData = bar_chart.all_weeks[currentWeekStr];
const maxWeight = Math.ceil(Math.max(...weights) / 5) * 5;
const rangeSize = maxWeight - minWeight < 11 ? 4 : 5; if (!weekData || !weekData.has_data) {
const ranges: string[] = []; return [];
for (let start = minWeight; start <= maxWeight; start += rangeSize) {
const end = start + rangeSize;
ranges.push(`${start}-${end}`);
} }
const totalIdealCount = detailsToUse.filter( return weekData.weight_distribution.map((range) => ({
(d) => d.range === 'Ideal' name: range.range,
).length; uv: range.bird_count,
isIdeal: range.is_ideal_range,
return ranges.map((range) => { idealRange: range.ideal_range,
const [minStr, maxStr] = range.split('-').map(Number); outsideRange: range.outside_range,
const min = minStr; }));
const max = maxStr; }, [chartData, currentWeekIndex]);
const birdsInRange = detailsToUse.filter(
(d) => d.weight >= min && d.weight < max
).length;
const hasIdeal = detailsToUse.some(
(d) => d.range === 'Ideal' && d.weight >= min && d.weight < max
);
return {
name: range,
uv: birdsInRange,
isIdeal: hasIdeal,
idealCount: hasIdeal ? totalIdealCount : undefined,
};
});
}, [uniformityData, detailsToUse]);
const gaugeChartData = useMemo(() => { const gaugeChartData = useMemo(() => {
if (!uniformityData) return undefined; if (!chartData?.gauge_chart || !uniformityData) return undefined;
const { gauge_chart } = chartData;
const currentWeekData = gauge_chart.available_weeks[currentWeekIndex];
if (!currentWeekData || !currentWeekData.has_data) {
return undefined;
}
const hasPrevWeek = currentWeekIndex > 0;
const hasNextWeek =
currentWeekIndex < gauge_chart.available_weeks.length - 1;
return { return {
value: uniformityData.uniformity, value: currentWeekData.uniformity_percentage,
label: 'Uniformity', label: 'Uniformity',
week: `Week ${uniformityData.week}`, week: `Week ${currentWeekData.week}`,
currentValue: uniformityData.uniform_qty, currentValue: currentWeekData.ideal_count,
totalValue: uniformityData.chick_qty_of_weight, totalValue: currentWeekData.total_count,
hasPrevWeek,
hasNextWeek,
}; };
}, [uniformityData]); }, [chartData, currentWeekIndex, uniformityData]);
const handleWeekChange = (direction: 'prev' | 'next') => {
if (!chartData?.gauge_chart) return;
const { available_weeks } = chartData.gauge_chart;
if (direction === 'prev' && currentWeekIndex > 0) {
setCurrentWeekIndex((prev) => prev - 1);
} else if (
direction === 'next' &&
currentWeekIndex < available_weeks.length - 1
) {
setCurrentWeekIndex((prev) => prev + 1);
}
};
const shouldShowEmptyState = !isFiltered;
return ( return (
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'> <section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'>
@@ -100,14 +111,16 @@ const UniformityChart = ({
}} }}
> >
<div className='w-full h-full flex items-center justify-center'> <div className='w-full h-full flex items-center justify-center'>
{!uniformityData || barChartData.length === 0 ? ( {shouldShowEmptyState ||
!uniformityData ||
barChartData.length === 0 ? (
<UniformityBarChartSkeleton /> <UniformityBarChartSkeleton />
) : ( ) : (
<UniformityBarChart data={barChartData} /> <UniformityBarChart data={barChartData} />
)} )}
</div> </div>
</Card> </Card>
{!uniformityData || !gaugeChartData ? ( {shouldShowEmptyState || !uniformityData || !gaugeChartData ? (
<Card <Card
variant='bordered' variant='bordered'
title='Weekly Performance ⓘ' title='Weekly Performance ⓘ'
@@ -133,6 +146,9 @@ const UniformityChart = ({
week={gaugeChartData.week} week={gaugeChartData.week}
currentValue={gaugeChartData.currentValue} currentValue={gaugeChartData.currentValue}
totalValue={gaugeChartData.totalValue} totalValue={gaugeChartData.totalValue}
onWeekChange={handleWeekChange}
hasPrevWeek={gaugeChartData.hasPrevWeek}
hasNextWeek={gaugeChartData.hasNextWeek}
/> />
</Card> </Card>
)} )}
@@ -51,8 +51,10 @@ import MenuItem from '@/components/menu/MenuItem';
const UniformityConfirmationPreview = ({ const UniformityConfirmationPreview = ({
uniformity, uniformity,
uniformityDetail,
}: { }: {
uniformity?: Uniformity; uniformity?: Uniformity;
uniformityDetail?: UniformityDetail;
}) => { }) => {
const data: DetailOptionType[] = [ const data: DetailOptionType[] = [
{ {
@@ -60,32 +62,42 @@ const UniformityConfirmationPreview = ({
label: 'Tanggal', label: 'Tanggal',
value: uniformity value: uniformity
? formatDate(uniformity.applied_at, 'DD MMM YYYY') ? formatDate(uniformity.applied_at, 'DD MMM YYYY')
: '-', : uniformityDetail
? formatDate(uniformityDetail.info_umum.tanggal, 'DD MMM YYYY')
: '-',
}, },
{ {
id: 'lokasi-farm', id: 'lokasi-farm',
label: 'Lokasi Farm', label: 'Lokasi Farm',
value: uniformity?.location_name || '-', value:
uniformity?.location_name ||
uniformityDetail?.info_umum?.lokasi_farm ||
'-',
}, },
{ {
id: 'project-flock', id: 'project-flock',
label: 'Project Flock', label: 'Project Flock',
value: uniformity?.flock_name || '-', value:
uniformity?.flock_name ||
uniformityDetail?.info_umum?.project_flock ||
'-',
}, },
{ {
id: 'kandang', id: 'kandang',
label: 'Kandang', label: 'Kandang',
value: uniformity?.kandang_name || '-', value:
uniformity?.kandang_name || uniformityDetail?.info_umum?.kandang || '-',
}, },
{ {
id: 'file-uniformity', id: 'file-uniformity',
label: 'File Uniformity', label: 'File Uniformity',
value: '-', value:
uniformity?.file_name || uniformityDetail?.info_umum?.file_name || '-',
}, },
{ {
id: 'status', id: 'status',
label: 'Status', label: 'Status',
value: uniformity?.status || '-', value: uniformity?.status || (uniformityDetail ? 'CREATED' : '-'),
}, },
]; ];
@@ -139,8 +151,10 @@ const UniformityConfirmationPreview = ({
const UniformityChartWrapper = ({ const UniformityChartWrapper = ({
uniformitySwrKey, uniformitySwrKey,
isFiltered,
}: { }: {
uniformitySwrKey: string; uniformitySwrKey: string;
isFiltered: boolean;
}) => { }) => {
const { data: uniformities } = useSWR( const { data: uniformities } = useSWR(
uniformitySwrKey, uniformitySwrKey,
@@ -154,31 +168,8 @@ const UniformityChartWrapper = ({
return null; return null;
}, [uniformities]); }, [uniformities]);
const shouldFetchDetails = !!uniformityData;
const uniformityDetailSwrKey = useMemo(() => {
if (!uniformityData) return null;
return `${UniformityApi.basePath}/${uniformityData.id}?with_details=true`;
}, [uniformityData]);
const { data: uniformityDetailResponse } = useSWR(
uniformityDetailSwrKey,
shouldFetchDetails ? UniformityApi.getAllFetcher : null
);
const uniformityDetails = useMemo(() => {
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
const detailData =
uniformityDetailResponse.data as unknown as UniformityDetail;
return detailData.uniformity_details;
}
return undefined;
}, [shouldFetchDetails, uniformityDetailResponse]);
return ( return (
<UniformityChart <UniformityChart uniformityData={uniformityData} isFiltered={isFiltered} />
uniformityData={uniformityData}
uniformityDetails={uniformityDetails}
/>
); );
}; };
@@ -239,12 +230,15 @@ const UniformityTable = () => {
const [filterStartDate, setFilterStartDate] = useState(''); const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState('');
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const { const {
setInputValue: setFilterLocationInputValue, setInputValue: setFilterLocationInputValue,
options: filterLocationOptions, options: filterLocationOptions,
isLoadingOptions: isLoadingFilterLocations, isLoadingOptions: isLoadingFilterLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search'); } = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
limit: '100',
});
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER ===== // ===== FETCH PROJECT FLOCKS DATA FOR FILTER =====
const filterProjectFlocksUrl = useMemo(() => { const filterProjectFlocksUrl = useMemo(() => {
@@ -316,6 +310,7 @@ const UniformityTable = () => {
project_flock_id: filterProjectFlock.value.toString(), project_flock_id: filterProjectFlock.value.toString(),
kandang_id: filterKandang.value.toString(), kandang_id: filterKandang.value.toString(),
withpopulation: Boolean(true).toString(), withpopulation: Boolean(true).toString(),
limit: '100',
}); });
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [filterProjectFlock, filterKandang]); }, [filterProjectFlock, filterKandang]);
@@ -362,6 +357,7 @@ const UniformityTable = () => {
if (filterEndDate) { if (filterEndDate) {
queryParams.append('end_date', filterEndDate); queryParams.append('end_date', filterEndDate);
} }
queryParams.append('with_chart', 'true');
} }
const tableQueryString = getTableFilterQueryString(); const tableQueryString = getTableFilterQueryString();
@@ -421,6 +417,7 @@ const UniformityTable = () => {
); );
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterLocation(null); setFilterLocation(null);
setFilterProjectFlock(null); setFilterProjectFlock(null);
setFilterKandang(null); setFilterKandang(null);
@@ -430,9 +427,38 @@ const UniformityTable = () => {
}, []); }, []);
const handleApplyFilters = useCallback(() => { const handleApplyFilters = useCallback(() => {
setIsSubmitted(true); const errors: Record<string, string> = {};
filterModal.closeModal();
}, [filterModal]); if (!filterStartDate) {
errors.start_date = 'Tanggal mulai wajib diisi';
}
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi';
}
if (!filterLocation) {
errors.location = 'Lokasi wajib dipilih';
}
if (!filterProjectFlock) {
errors.project_flock = 'Project Flock wajib dipilih';
}
if (!filterKandang) {
errors.kandang = 'Kandang wajib dipilih';
}
setFilterErrors(errors);
if (Object.keys(errors).length === 0) {
setIsSubmitted(true);
filterModal.closeModal();
}
}, [
filterModal,
filterStartDate,
filterEndDate,
filterLocation,
filterProjectFlock,
filterKandang,
]);
const selectedRowIds = useMemo(() => { const selectedRowIds = useMemo(() => {
return Object.keys(rowSelection) return Object.keys(rowSelection)
@@ -448,9 +474,15 @@ const UniformityTable = () => {
const canApproveReject = useMemo(() => { const canApproveReject = useMemo(() => {
return ( return (
selectedUniformities.length > 0 && selectedUniformities.length > 0 &&
selectedUniformities.every( selectedUniformities.every((u) => {
(u) => u.status === 'CREATED' || u.status === 'Pengajuan' const approvalAction = u.latest_approval?.action;
) return (
approvalAction === 'CREATED' ||
approvalAction === 'Pengajuan' ||
(!approvalAction &&
(u.status === 'CREATED' || u.status === 'Pengajuan'))
);
})
); );
}, [selectedUniformities]); }, [selectedUniformities]);
@@ -615,7 +647,7 @@ const UniformityTable = () => {
if (filterEndDate) { if (filterEndDate) {
queryParams.append('end_date', filterEndDate); queryParams.append('end_date', filterEndDate);
} }
queryParams.append('limit', '10000'); queryParams.append('limit', '100');
queryParams.append('page', '1'); queryParams.append('page', '1');
const queryString = queryParams.toString(); const queryString = queryParams.toString();
@@ -805,7 +837,9 @@ const UniformityTable = () => {
accessorKey: 'status', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const status = props.row.original.status; const uniformity = props.row.original;
const status =
uniformity.latest_approval?.action ?? uniformity.status;
return ( return (
<div className='w-full'> <div className='w-full'>
<Badge <Badge
@@ -876,7 +910,10 @@ const UniformityTable = () => {
<div className='my-4 divider'></div> <div className='my-4 divider'></div>
<section> <section>
<UniformityChartWrapper uniformitySwrKey={uniformitySwrKey} /> <UniformityChartWrapper
uniformitySwrKey={uniformitySwrKey}
isFiltered={isSubmitted}
/>
</section> </section>
<Card <Card
@@ -938,34 +975,7 @@ const UniformityTable = () => {
<div className='flex flex-col gap-4 mt-4'> <div className='flex flex-col gap-4 mt-4'>
{createdUniformity ? ( {createdUniformity ? (
<UniformityConfirmationPreview <UniformityConfirmationPreview
uniformity={{ uniformityDetail={createdUniformity}
id: createdUniformity.id,
location_name: createdUniformity.info_umum.lokasi_farm,
flock_name: createdUniformity.info_umum.project_flock,
kandang_name: createdUniformity.info_umum.kandang,
applied_at: createdUniformity.info_umum.tanggal,
week: 0,
status: 'Pengajuan',
uniformity: createdUniformity.result.uniformity,
cv: createdUniformity.result.cv,
chick_qty_of_weight:
createdUniformity.sampling.chick_qty_of_weight,
uniform_qty: createdUniformity.result.uniform_qty,
mean_up: createdUniformity.sampling.mean_up,
mean_down: createdUniformity.sampling.mean_down,
standard_mean_weight: null,
standard_uniformity: null,
created_at: '',
created_by: 0,
project_flock_kandang_id: 0,
created_user: {
id: 0,
id_user: 0,
email: '',
name: '',
},
updated_at: '',
}}
/> />
) : selectedRowIds.length === 1 ? ( ) : selectedRowIds.length === 1 ? (
<UniformityConfirmationPreview <UniformityConfirmationPreview
@@ -1147,58 +1157,105 @@ const UniformityTable = () => {
</div> </div>
<div className='space-y-4 px-4'> <div className='space-y-4 px-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
<DateInput <div>
label='Tanggal' <DateInput
name='start_date' label='Tanggal'
value={filterStartDate} name='start_date'
onChange={(e) => setFilterStartDate(e.target.value)} value={filterStartDate}
className={{ wrapper: 'w-full' }} onChange={(e) => {
/> setFilterStartDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div>
<DateInput <div>
label=' ' <DateInput
name='end_date' label=' '
value={filterEndDate} name='end_date'
onChange={(e) => setFilterEndDate(e.target.value)} value={filterEndDate}
className={{ wrapper: 'w-full' }} onChange={(e) => {
/> setFilterEndDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div>
</div> </div>
<SelectInput <div>
label='Lokasi' <SelectInput
placeholder='Pilih Lokasi...' label='Lokasi'
value={filterLocation} placeholder='Pilih Lokasi...'
onChange={handleFilterLocationChange} value={filterLocation}
options={filterLocationOptions} onChange={(value) => {
onInputChange={setFilterLocationInputValue} handleFilterLocationChange(value);
isLoading={isLoadingFilterLocations} setFilterErrors((prev) => ({ ...prev, location: '' }));
isClearable }}
className={{ wrapper: 'w-full' }} options={filterLocationOptions}
/> onInputChange={setFilterLocationInputValue}
isLoading={isLoadingFilterLocations}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.location && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.location}
</p>
)}
</div>
<SelectInput <div>
label='Project Flock' <SelectInput
placeholder='Pilih Project Flock...' label='Project Flock'
value={filterProjectFlock} placeholder='Pilih Project Flock...'
onChange={handleFilterProjectFlockChange} value={filterProjectFlock}
options={filterProjectFlockOptions} onChange={(value) => {
onInputChange={setProjectFlockSearchValue} handleFilterProjectFlockChange(value);
isLoading={isLoadingFilterProjectFlocks} setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
isDisabled={!filterLocation} }}
isClearable options={filterProjectFlockOptions}
className={{ wrapper: 'w-full' }} onInputChange={setProjectFlockSearchValue}
/> isLoading={isLoadingFilterProjectFlocks}
isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.project_flock && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.project_flock}
</p>
)}
</div>
<SelectInput <div>
label='Kandang' <SelectInput
placeholder='Pilih Kandang...' label='Kandang'
value={filterKandang} placeholder='Pilih Kandang...'
onChange={handleFilterKandangChange} value={filterKandang}
options={filterKandangOptions} onChange={(value) => {
isDisabled={!filterProjectFlock} handleFilterKandangChange(value);
isClearable setFilterErrors((prev) => ({ ...prev, kandang: '' }));
className={{ wrapper: 'w-full' }} }}
/> options={filterKandangOptions}
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.kandang && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.kandang}
</p>
)}
</div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
@@ -27,7 +27,8 @@ interface BarChartData {
name: string; name: string;
uv: number; uv: number;
isIdeal?: boolean; isIdeal?: boolean;
idealCount?: number; idealRange?: string;
outsideRange?: string;
} }
interface UniformityBarChartProps { interface UniformityBarChartProps {
@@ -40,30 +41,117 @@ function CustomTooltip({ payload, label, active }: CustomTooltipProps) {
const chartData = data.payload as BarChartData; const chartData = data.payload as BarChartData;
const labelStr = String(label); const labelStr = String(label);
if (chartData.isIdeal && chartData.idealCount !== undefined) { // If the range has both ideal and outside ranges (like 340-344)
if (chartData.idealRange && chartData.outsideRange) {
return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
<div className='flex flex-col gap-2 mt-2'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
<span className='text-sm'>Ideal</span>
</div>
<span className='text-sm font-medium'>
{chartData.idealRange}
</span>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#EF4444] rounded-md'></div>
<span className='text-sm'>Outside</span>
</div>
<span className='text-sm font-medium'>
{chartData.outsideRange}
</span>
</div>
<div className='border-t border-white/20 pt-2 mt-1'>
<div className='flex items-center justify-between'>
<span className='text-white/70 text-sm'>Total Birds:</span>
<span className='font-semibold'>{payload[0].value}</span>
</div>
</div>
<div className='text-center text-xs text-white/50'>{labelStr}</div>
</div>
</div>
);
}
// If the range has only ideal range
if (chartData.idealRange) {
return ( return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'> <div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p> <p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
<div className='flex items-center gap-2 mt-2 justify-between'> <div className='flex items-center gap-2 mt-2 justify-between'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div> <div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
{chartData.idealCount} of Birds <span className='text-sm'>Ideal</span>
</div> </div>
<span>{labelStr}</span> <span className='text-sm font-medium'>{chartData.idealRange}</span>
</div>
<div className='border-t border-white/20 pt-2 mt-2'>
<div className='flex items-center justify-between'>
<span className='text-white/70 text-sm'>Birds:</span>
<span className='font-semibold'>{payload[0].value}</span>
</div>
</div>
<div className='text-center text-xs text-white/50 mt-1'>
{labelStr}
</div> </div>
</div> </div>
); );
} }
// If the range has only outside range
if (chartData.outsideRange) {
return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
<div className='flex items-center gap-2 mt-2 justify-between'>
<div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#EF4444] rounded-md'></div>
<span className='text-sm'>Outside</span>
</div>
<span className='text-sm font-medium'>
{chartData.outsideRange}
</span>
</div>
<div className='border-t border-white/20 pt-2 mt-2'>
<div className='flex items-center justify-between'>
<span className='text-white/70 text-sm'>Birds:</span>
<span className='font-semibold'>{payload[0].value}</span>
</div>
</div>
<div className='text-center text-xs text-white/50 mt-1'>
{labelStr}
</div>
</div>
);
}
// Fallback for backward compatibility
return ( return (
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'> <div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p> <p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
<div className='flex items-center gap-2 mt-2 justify-between'> <div className='flex items-center gap-2 mt-2 justify-between'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div> <div
{payload[0].value} of Birds className='w-5 h-5 rounded-md'
style={{
backgroundColor: chartData.isIdeal ? '#0069E0' : '#EF4444',
}}
></div>
<span className='text-sm'>
{chartData.isIdeal ? 'Ideal' : 'Outside'}
</span>
</div>
<span className='text-sm font-medium'>{labelStr}</span>
</div>
<div className='border-t border-white/20 pt-2 mt-2'>
<div className='flex items-center justify-between'>
<span className='text-white/70 text-sm'>Birds:</span>
<span className='font-semibold'>{payload[0].value}</span>
</div> </div>
<span>{labelStr}</span>
</div> </div>
</div> </div>
); );
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useEffect } from 'react'; import { useMemo, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
@@ -11,9 +11,12 @@ import Badge from '@/components/Badge';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity'; import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity';
import { formatDate } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview'; import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview';
import { UniformityApi } from '@/services/api/uniformity';
import useSWR from 'swr';
import { isResponseSuccess } from '@/lib/api-helper';
import { import {
getStatusColor, getStatusColor,
getStatusIndicatorColor, getStatusIndicatorColor,
@@ -33,6 +36,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
const setExpandedDrawerContent = useUiStore( const setExpandedDrawerContent = useUiStore(
(s) => s.setExpandedDrawerContent (s) => s.setExpandedDrawerContent
); );
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
const [hasFetchedDetails, setHasFetchedDetails] = useState(false);
const { data: uniformityDetailResponse, isLoading } = useSWR(
shouldFetchDetails
? `uniformity-detail-${initialValues.id}-with-details`
: null,
() => UniformityApi.getUniformityDetail(initialValues.id, true)
);
const uniformity_details = useMemo(() => {
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
return uniformityDetailResponse.data.uniformity_details;
}
return initialValues.uniformity_details;
}, [shouldFetchDetails, uniformityDetailResponse, initialValues]);
const handleApprove = () => { const handleApprove = () => {
router.push(`/production/uniformity?action=approve&id=${initialValues.id}`); router.push(`/production/uniformity?action=approve&id=${initialValues.id}`);
@@ -43,12 +62,15 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}; };
const handleViewUniformityDetails = () => { const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent( setExpandedDrawerContent(
<UniformityDetailsPreview <UniformityDetailsPreview
info_umum={initialValues.info_umum} info_umum={initialValues.info_umum}
uniformity_details={initialValues.uniformity_details} uniformity_details={uniformity_details}
sampling={initialValues.sampling}
result={initialValues.result}
uniformityId={initialValues.id} uniformityId={initialValues.id}
/> />
); );
@@ -58,6 +80,28 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}, 0); }, 0);
}; };
useEffect(() => {
if (
shouldFetchDetails &&
uniformity_details &&
uniformity_details.length > 0 &&
!hasFetchedDetails
) {
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={uniformity_details}
uniformityId={initialValues.id}
/>
);
setHasFetchedDetails(true);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
}
}, [shouldFetchDetails, uniformity_details, hasFetchedDetails]);
useEffect(() => { useEffect(() => {
return () => { return () => {
setExpandedDrawerOpen(false); setExpandedDrawerOpen(false);
@@ -154,12 +198,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
return ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span>{valueMap[id]}</span> <span>{valueMap[id]}</span>
<Tooltip content='Lihat Detail'> <Tooltip content='Lihat Detail' position='left'>
<button <button
className='p-1 hover:bg-gray-100 rounded cursor-pointer' className='p-1 hover:bg-gray-100 rounded cursor-pointer disabled:opacity-50'
onClick={handleViewUniformityDetails} onClick={handleViewUniformityDetails}
disabled={isLoading}
> >
<Icon icon='mdi:eye-outline' width={18} height={18} /> {isLoading ? (
<Icon
icon='mdi:loading'
width={18}
height={18}
className='animate-spin'
/>
) : (
<Icon icon='mdi:eye-outline' width={18} height={18} />
)}
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -173,6 +227,92 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
[initialValues] [initialValues]
); );
const samplingTableData: DetailOptionType[] = useMemo(() => {
if (!initialValues.sampling) return [];
return [
{
id: 'sampling-size',
label: 'Sampling size',
value: `${formatNumber(initialValues.sampling.chick_qty_of_weight)} of Birds`,
},
{
id: 'mean-weight',
label: 'Mean Weight',
value: `${initialValues.sampling.mean_weight} g`,
},
{
id: 'min-limit',
label: 'Min Limit (-10%)',
value: `${initialValues.sampling.mean_down} g`,
},
{
id: 'max-limit',
label: 'Max Limit (+10%)',
value: `${initialValues.sampling.mean_up} g`,
},
];
}, [initialValues.sampling]);
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const resultTableData: DetailOptionType[] = useMemo(() => {
if (!initialValues.result) return [];
return [
{
id: 'ideal-birds',
label: 'Ideal Birds',
value: `${formatNumber(initialValues.result.uniform_qty)} of Birds`,
},
{
id: 'outside-range',
label: 'Outside Range',
value: `${formatNumber(initialValues.result.outside_qty)} of Birds`,
},
{
id: 'uniformity',
label: 'Uniformity',
value: `${initialValues.result.uniformity} %`,
},
{
id: 'cv',
label: 'CV',
value: `${initialValues.result.cv} %`,
},
];
}, [initialValues.result]);
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
return ( return (
<section className='w-full h-full bg-white border-l border-gray-200'> <section className='w-full h-full bg-white border-l border-gray-200'>
{/* Header */} {/* Header */}
@@ -185,7 +325,7 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <div className='divider mt-3.5'></div>
<section className='w-full px-6'> <section className='w-full px-6 mb-6'>
{initialValues ? ( {initialValues ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Info Umum */} {/* Info Umum */}
@@ -200,23 +340,55 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
/> />
{/* Approve/Reject Buttons */}
{initialValues.result &&
initialValues.latest_approval?.step_name === 'CREATED' ? (
<>
<div className='divider my-3.5' />
<RequirePermission permissions='lti.production.uniformity.approve'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
<Button variant='outline' onClick={handleReject}>
Reject
</Button>
<Button onClick={handleApprove}>Approve</Button>
</div>
</RequirePermission>
</>
) : null}
</div> </div>
{/* Sampling and Range */}
{initialValues.sampling && (
<div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
<Table<DetailOptionType>
data={samplingTableData}
columns={columnsSampling}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{/* Result */}
{initialValues.result && (
<div className=''>
<p className='text-sm font-medium mb-5'>Result</p>
<Table<DetailOptionType>
data={resultTableData}
columns={resultColumns}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{/* Approve/Reject Buttons */}
{initialValues.result &&
initialValues.latest_approval?.step_name === 'CREATED' ? (
<>
<div className='divider my-3.5' />
<RequirePermission permissions='lti.production.uniformity.approve'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
<Button variant='outline' onClick={handleReject}>
Reject
</Button>
<Button onClick={handleApprove}>Approve</Button>
</div>
</RequirePermission>
</>
) : null}
</div> </div>
) : ( ) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'> <div className='flex flex-col items-center justify-center py-10 text-gray-400'>
@@ -1,152 +1,39 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { import {
UniformityDetailItem, UniformityDetailItem,
UniformitySampling,
UniformityResult,
UniformityInfoUmum, UniformityInfoUmum,
} from '@/types/api/production/uniformity'; } from '@/types/api/production/uniformity';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { formatNumber } from '@/lib/helper';
import { DetailOptionType } from '@/types/api/production/uniformity';
import { import {
getWeightStatusColor, getWeightStatusColor,
getWeightStatusIndicatorColor, getWeightStatusIndicatorColor,
getWeightStatusText, getWeightStatusText,
} from '@/components/pages/production/uniformity/uniformity-utils'; } from '@/components/pages/production/uniformity/uniformity-utils';
import { BodyWeightData } from '@/types/api/production/uniformity'; import { BodyWeightData } from '@/types/api/production/uniformity';
import Button from '@/components/Button';
import { UniformityApi } from '@/services/api/uniformity';
import useSWR from 'swr';
import { isResponseSuccess } from '@/lib/api-helper';
interface UniformityDetailsPreviewProps { interface UniformityDetailsPreviewProps {
info_umum: UniformityInfoUmum; info_umum: UniformityInfoUmum;
sampling: UniformitySampling;
result: UniformityResult;
uniformity_details?: UniformityDetailItem[]; uniformity_details?: UniformityDetailItem[];
uniformityId: number; uniformityId: number;
} }
const UniformityDetailsPreview = ({ const UniformityDetailsPreview = ({
info_umum, info_umum,
uniformity_details: initialUniformityDetails, uniformity_details,
sampling,
result,
uniformityId,
}: UniformityDetailsPreviewProps) => { }: UniformityDetailsPreviewProps) => {
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen); const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
const { data: uniformityDetailResponse, isLoading } = useSWR(
shouldFetchDetails
? `uniformity-detail-${uniformityId}-with-details`
: null,
() => UniformityApi.getUniformityDetail(uniformityId, true)
);
const uniformity_details = useMemo(() => {
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
return uniformityDetailResponse.data.uniformity_details;
}
return initialUniformityDetails;
}, [shouldFetchDetails, uniformityDetailResponse, initialUniformityDetails]);
const handleClose = () => { const handleClose = () => {
setExpandedDrawerOpen(false); setExpandedDrawerOpen(false);
}; };
const fetchWeightData = () => {
setShouldFetchDetails(true);
};
const samplingTableData: DetailOptionType[] = useMemo(() => {
if (!sampling) return [];
return [
{
id: 'sampling-size',
label: 'Sampling size',
value: `${formatNumber(sampling.chick_qty_of_weight)} of Birds`,
},
{
id: 'mean-weight',
label: 'Mean Weight',
value: `${sampling.mean_weight} g`,
},
{
id: 'min-limit',
label: 'Min Limit (-10%)',
value: `${sampling.mean_down} g`,
},
{
id: 'max-limit',
label: 'Max Limit (+10%)',
value: `${sampling.mean_up} g`,
},
];
}, [sampling]);
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const resultTableData: DetailOptionType[] = useMemo(() => {
if (!result) return [];
return [
{
id: 'ideal-birds',
label: 'Ideal Birds',
value: `${formatNumber(result.uniform_qty)} of Birds`,
},
{
id: 'outside-range',
label: 'Outside Range',
value: `${formatNumber(result.outside_qty)} of Birds`,
},
{
id: 'uniformity',
label: 'Uniformity',
value: `${result.uniformity} %`,
},
];
}, [result]);
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
() => [
{
accessorKey: 'label',
header: 'Label',
cell: (props) => props.row.original.label,
},
{
accessorKey: 'value',
header: 'Value',
cell: (props) => <span>{props.row.original.value}</span>,
},
],
[]
);
const tableData = useMemo(() => { const tableData = useMemo(() => {
if (!uniformity_details) return []; if (!uniformity_details) return [];
@@ -229,55 +116,10 @@ const UniformityDetailsPreview = ({
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <div className='divider mt-3.5'></div>
<section className='w-full px-6'> <section className='w-full px-6'>
{info_umum || sampling || result ? ( {info_umum ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Sampling and Range */}
{sampling && (
<div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
<Table<DetailOptionType>
data={samplingTableData}
columns={columnsSampling}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{/* Result */}
{result && (
<div className=''>
<p className='text-sm font-medium mb-5'>Result</p>
<Table<DetailOptionType>
data={resultTableData}
columns={resultColumns}
pageSize={4}
className={{
containerClassName: 'mb-0',
paginationClassName: 'hidden',
}}
/>
</div>
)}
{!uniformity_details || uniformity_details.length === 0 ? (
<div className='mt-4'>
<Button
type='button'
onClick={fetchWeightData}
disabled={isLoading}
className='w-full'
>
{isLoading ? 'Loading...' : 'Show Body Weight Details'}
</Button>
</div>
) : null}
{/* Body Weight Details */} {/* Body Weight Details */}
{uniformity_details && uniformity_details.length > 0 && ( {uniformity_details && uniformity_details.length > 0 ? (
<div className='mt-4'> <div className='mt-4'>
<Table<BodyWeightData> <Table<BodyWeightData>
data={tableData} data={tableData}
@@ -286,6 +128,17 @@ const UniformityDetailsPreview = ({
className={{ containerClassName: 'mb-5' }} className={{ containerClassName: 'mb-5' }}
/> />
</div> </div>
) : (
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
<Icon
icon='mdi:file-document-outline'
width={64}
height={64}
className='mb-4'
/>
<p className='text-lg'>No data available</p>
<p className='text-sm'>Body weight details not found</p>
</div>
)} )}
</div> </div>
) : ( ) : (
@@ -24,9 +24,9 @@ type UniformityFormSchemaType = {
}; };
const FileSchema = Yup.mixed<File>() const FileSchema = Yup.mixed<File>()
.test('documentSize', 'Ukuran file maksimal 2 MB', (value): boolean => { .test('documentSize', 'Ukuran file maksimal 5 MB', (value): boolean => {
if (!value) return true; if (!value) return true;
if (value instanceof File) return value.size <= 2 * 1024 * 1024; if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return false; return false;
}) })
.test('documentType', 'Format file harus Excel', (value): boolean => { .test('documentType', 'Format file harus Excel', (value): boolean => {
@@ -43,7 +43,9 @@ import UniformityResultForm from '@/components/pages/production/uniformity/form/
import { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate'; import { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate';
import useSWR from 'swr'; import useSWR from 'swr';
import { cn, formatNumber } from '@/lib/helper'; import { cn, formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface UniformityFormProps { interface UniformityFormProps {
formType?: 'add' | 'edit'; formType?: 'add' | 'edit';
@@ -77,6 +79,7 @@ const UniformityForm = ({
const [uniformityFormErrorMessage, setUniformityFormErrorMessage] = const [uniformityFormErrorMessage, setUniformityFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -282,6 +285,22 @@ const UniformityForm = ({
}, },
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const handleLocationChange = useCallback( const handleLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -339,8 +358,8 @@ const UniformityForm = ({
return; return;
} }
if (document.size > 2 * 1024 * 1024) { if (document.size > 5 * 1024 * 1024) {
toast.error(`Ukuran file ${document.name} maksimal 2 MB!`); toast.error(`Ukuran file ${document.name} maksimal 5 MB!`);
return; return;
} }
@@ -454,7 +473,7 @@ const UniformityForm = ({
<section className='w-full px-6 mb-6'> <section className='w-full px-6 mb-6'>
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2> <h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2>
<form onSubmit={formik.handleSubmit} className='flex flex-col gap-6'> <form onSubmit={handleFormSubmit} className='flex flex-col gap-6'>
{uniformityFormErrorMessage && ( {uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'> <div className='alert alert-error' role='alert'>
<Icon <Icon
@@ -466,6 +485,14 @@ const UniformityForm = ({
</div> </div>
)} )}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<DateInput <DateInput
required required
label='Tanggal' label='Tanggal'
@@ -693,7 +720,7 @@ const UniformityForm = ({
type='submit' type='submit'
color='primary' color='primary'
className='w-full' className='w-full'
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
{formik.isSubmitting ? ( {formik.isSubmitting ? (
<span className='loading loading-spinner'></span> <span className='loading loading-spinner'></span>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -14,6 +14,7 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
PurchaseRequestAcceptApprovalFormDefaultValues, PurchaseRequestAcceptApprovalFormDefaultValues,
@@ -28,6 +29,7 @@ import {
} from '@/types/api/purchase/purchase'; } from '@/types/api/purchase/purchase';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
@@ -52,7 +54,9 @@ const PurchaseOrderAcceptApprovalForm = ({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const isRejected = initialValues?.latest_approval?.action === 'REJECTED'; const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
@@ -67,7 +71,6 @@ const PurchaseOrderAcceptApprovalForm = ({
| 'purchase_item_id' | 'purchase_item_id'
| 'received_date' | 'received_date'
| 'travel_number' | 'travel_number'
| 'travel_document_path'
| 'vehicle_number' | 'vehicle_number'
| 'expedition_vendor_id' | 'expedition_vendor_id'
| 'received_qty' | 'received_qty'
@@ -180,7 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
purchase_item_id: formItem.purchase_item_id || 0, purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '', received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '', travel_number: formItem.travel_number || '',
travel_document_path: formItem.travel_document_path || '',
vehicle_number: formItem.vehicle_number || '', vehicle_number: formItem.vehicle_number || '',
expedition_vendor_id: formItem.expedition_vendor_id || 0, expedition_vendor_id: formItem.expedition_vendor_id || 0,
received_qty: received_qty:
@@ -210,6 +212,22 @@ const PurchaseOrderAcceptApprovalForm = ({
}, },
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
const purchaseItems = useMemo(() => { const purchaseItems = useMemo(() => {
if (initialValues?.items) { if (initialValues?.items) {
@@ -235,6 +253,9 @@ const PurchaseOrderAcceptApprovalForm = ({
useEffect(() => { useEffect(() => {
if (purchaseItems.length > 0 && initialValues?.items) { if (purchaseItems.length > 0 && initialValues?.items) {
const updatedItems = initialValues.items.map((item) => { const updatedItems = initialValues.items.map((item) => {
const expeditionVendorId =
item.expedition_vendor_id || item.expedition_vendor?.id || 0;
return { return {
purchase_item: null, purchase_item: null,
purchase_item_id: item.id, purchase_item_id: item.id,
@@ -242,7 +263,6 @@ const PurchaseOrderAcceptApprovalForm = ({
? new Date(item.received_date).toISOString().split('T')[0] ? new Date(item.received_date).toISOString().split('T')[0]
: '', : '',
travel_number: item.travel_number || '', travel_number: item.travel_number || '',
travel_document_path: item.travel_document_path || '',
vehicle_number: item.vehicle_number || '', vehicle_number: item.vehicle_number || '',
expedition_vendor: item.expedition_vendor expedition_vendor: item.expedition_vendor
? { ? {
@@ -250,7 +270,7 @@ const PurchaseOrderAcceptApprovalForm = ({
label: item.expedition_vendor.name, label: item.expedition_vendor.name,
} }
: null, : null,
expedition_vendor_id: item.expedition_vendor_id || 0, expedition_vendor_id: expeditionVendorId,
received_qty: item.total_qty || '', received_qty: item.total_qty || '',
transport_per_item: item.transport_per_item || '', transport_per_item: item.transport_per_item || '',
}; };
@@ -259,20 +279,6 @@ const PurchaseOrderAcceptApprovalForm = ({
} }
}, [purchaseItems, initialValues, key]); }, [purchaseItems, initialValues, key]);
useEffect(() => {
if (
formik.values.travel_documents &&
formik.values.travel_documents.length > 0
) {
const fileNames = formik.values.travel_documents
.map((file) => file.name)
.join(', ');
formik.values.items?.forEach((item, idx) => {
formik.setFieldValue(`items.${idx}.travel_document_path`, fileNames);
});
}
}, [formik.values.travel_documents]);
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
const getQuantityExceededError = useCallback( const getQuantityExceededError = useCallback(
(idx: number, receivedQty: number) => { (idx: number, receivedQty: number) => {
@@ -349,7 +355,7 @@ const PurchaseOrderAcceptApprovalForm = ({
return ( return (
<form <form
key={key} key={key}
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
className='w-full flex flex-col gap-6' className='w-full flex flex-col gap-6'
> >
<div className='w-full'> <div className='w-full'>
@@ -358,6 +364,24 @@ const PurchaseOrderAcceptApprovalForm = ({
? 'Konfirmasi Penerimaan Produk' ? 'Konfirmasi Penerimaan Produk'
: 'Edit Penerimaan Produk'} : 'Edit Penerimaan Produk'}
</h2> </h2>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
@@ -510,33 +534,6 @@ const PurchaseOrderAcceptApprovalForm = ({
}} }}
/> />
</td> </td>
<td className='hidden'>
<TextInput
required
name={`items.${idx}.travel_document_path`}
type='text'
value={formItem?.travel_document_path || ''}
onChange={(e) =>
formik.setFieldValue(
`items.${idx}.travel_document_path`,
e.target.value
)
}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(idx, 'travel_document_path')
.isError
}
errorMessage={
isRepeaterInputError(idx, 'travel_document_path')
.errorMessage
}
placeholder='Masukkan path dokumen'
className={{
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
</td>
<td> <td>
<TextInput <TextInput
required required
@@ -687,8 +684,19 @@ const PurchaseOrderAcceptApprovalForm = ({
name='travel_documents' name='travel_documents'
label='Dokumen Surat Jalan' label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
ref={fileInputRef}
onChange={(e) => { onChange={(e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const invalidFiles = files.filter(
(file) => file.size > 5 * 1024 * 1024
);
if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = '';
return;
}
formik.setFieldValue('travel_documents', files); formik.setFieldValue('travel_documents', files);
}} }}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -716,6 +724,10 @@ const PurchaseOrderAcceptApprovalForm = ({
onClick={() => { onClick={() => {
if (type === 'add') { if (type === 'add') {
formik.resetForm(); formik.resetForm();
formik.setFieldValue('travel_documents', []);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} }
setPurchaseOrderFormErrorMessage(''); setPurchaseOrderFormErrorMessage('');
onCancel?.(); onCancel?.();
@@ -731,27 +743,13 @@ const PurchaseOrderAcceptApprovalForm = ({
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={
!formik.isValid || formik.isSubmitting || hasQuantityExceededErrors || isRejected
formik.isSubmitting ||
hasQuantityExceededErrors ||
isRejected
} }
> >
Submit Submit
</Button> </Button>
</div> </div>
</div> </div>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
</div> </div>
</form> </form>
); );
@@ -38,7 +38,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
travel_document_path: string;
vehicle_number: string; vehicle_number: string;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
@@ -76,7 +75,6 @@ export type PurchaseAcceptApprovalItemSchema = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
travel_document_path: string;
vehicle_number: string; vehicle_number: string;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
@@ -185,9 +183,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
travel_number: Yup.string() travel_number: Yup.string()
.required('No. Surat jalan wajib diisi!') .required('No. Surat jalan wajib diisi!')
.typeError('No. Surat jalan wajib diisi!'), .typeError('No. Surat jalan wajib diisi!'),
travel_document_path: Yup.string()
.required('Dokumen Surat jalan wajib diisi!')
.typeError('Dokumen Surat jalan wajib diisi!'),
vehicle_number: Yup.string() vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!') .required('Nomor kendaraan wajib diisi!')
.typeError('Nomor kendaraan wajib diisi!'), .typeError('Nomor kendaraan wajib diisi!'),
@@ -312,7 +307,8 @@ export const PurchaseRequestStaffApprovalFormInitialValues: PurchaseRequestStaff
}; };
export const PurchaseRequestStaffApprovalFormDefaultValues = ( export const PurchaseRequestStaffApprovalFormDefaultValues = (
purchase?: Purchase purchase?: Purchase,
type?: 'add' | 'edit'
): PurchaseRequestStaffApprovalFormSchemaType => { ): PurchaseRequestStaffApprovalFormSchemaType => {
return { return {
action: 'APPROVED', action: 'APPROVED',
@@ -331,8 +327,18 @@ export const PurchaseRequestStaffApprovalFormDefaultValues = (
label: item.warehouse?.name || '', label: item.warehouse?.name || '',
}, },
qty: item.sub_qty || item.qty || 0, qty: item.sub_qty || item.qty || 0,
price: item.price, price:
total_price: item.total_price, type === 'add'
? 'ProductPrice' in item.product
? item.product.ProductPrice || item.price || ''
: item.price
: item.price,
total_price:
type === 'add'
? ('ProductPrice' in item.product
? item.product.ProductPrice || item.price || 0
: item.price) * (item.sub_qty || item.qty || 0)
: item.total_price,
})) }))
: [ : [
{ {
@@ -381,7 +387,15 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
.required('Item pembelian wajib diisi!') .required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!'), .typeError('Item pembelian wajib diisi!'),
travel_documents: Yup.array() travel_documents: Yup.array()
.of(Yup.mixed<File>().required()) .of(
Yup.mixed<File>()
.required('Dokumen surat jalan wajib diupload!')
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
})
)
.required('Dokumen surat jalan wajib diupload!') .required('Dokumen surat jalan wajib diupload!')
.min(1, 'Minimal upload 1 dokumen surat jalan!') .min(1, 'Minimal upload 1 dokumen surat jalan!')
.typeError('Dokumen surat jalan wajib diupload!'), .typeError('Dokumen surat jalan wajib diupload!'),
@@ -396,7 +410,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
purchase_item_id: 0, purchase_item_id: 0,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -417,7 +430,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase_item_id: item.id, purchase_item_id: item.id,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -428,7 +440,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase_item_id: 0, purchase_item_id: 0,
received_date: '', received_date: '',
travel_number: '', travel_number: '',
travel_document_path: '',
vehicle_number: '', vehicle_number: '',
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
@@ -12,10 +12,12 @@ import NumberInput from '@/components/input/NumberInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { SupplierProducts } from '@/types/api/master-data/supplier'; import { SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { import {
PurchaseRequestStaffApprovalFormDefaultValues, PurchaseRequestStaffApprovalFormDefaultValues,
PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormInitialValues,
@@ -87,6 +89,7 @@ const PurchaseOrderStaffApprovalForm = ({
const deleteModal = useModal(); const deleteModal = useModal();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const [selectedItemForDelete, setSelectedItemForDelete] = useState< const [selectedItemForDelete, setSelectedItemForDelete] = useState<
number | null number | null
>(null); >(null);
@@ -294,9 +297,9 @@ const PurchaseOrderStaffApprovalForm = ({
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo(() => { const formikInitialValues = useMemo(() => {
return initialValues return initialValues
? PurchaseRequestStaffApprovalFormDefaultValues(initialValues) ? PurchaseRequestStaffApprovalFormDefaultValues(initialValues, type)
: PurchaseRequestStaffApprovalFormInitialValues; : PurchaseRequestStaffApprovalFormInitialValues;
}, [initialValues]); }, [initialValues, type]);
const formik = useFormik({ const formik = useFormik({
initialValues: formikInitialValues, initialValues: formikInitialValues,
@@ -415,6 +418,22 @@ const PurchaseOrderStaffApprovalForm = ({
}, },
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
const supplierProductOptions = baseSupplierProductOptions; const supplierProductOptions = baseSupplierProductOptions;
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
@@ -485,9 +504,18 @@ const PurchaseOrderStaffApprovalForm = ({
}, },
warehouse_id: purchaseItem.warehouse_id || 0, warehouse_id: purchaseItem.warehouse_id || 0,
qty: originalItem?.qty || purchaseItem.quantity || 0, qty: originalItem?.qty || purchaseItem.quantity || 0,
price: type === 'edit' && originalItem ? originalItem.price : '', price:
type === 'edit' && originalItem
? originalItem.price
: originalItem?.product && 'ProductPrice' in originalItem.product
? originalItem.product.ProductPrice || ''
: '',
total_price: total_price:
type === 'edit' && originalItem ? originalItem.total_price : '', type === 'edit' && originalItem
? originalItem.total_price
: (originalItem?.product && 'ProductPrice' in originalItem.product
? originalItem.product.ProductPrice || 0
: 0) * (originalItem?.qty || purchaseItem.quantity || 0),
}; };
return itemData; return itemData;
}); });
@@ -643,16 +671,32 @@ const PurchaseOrderStaffApprovalForm = ({
return ( return (
<> <>
<form <form onSubmit={handleFormSubmit} className='w-full flex flex-col gap-6'>
onSubmit={formik.handleSubmit}
className='w-full flex flex-col gap-6'
>
<div className='w-full'> <div className='w-full'>
<h2 className='text-lg font-semibold mb-4'> <h2 className='text-lg font-semibold mb-4'>
{type === 'add' {type === 'add'
? 'Konfirmasi Item Pembelian' ? 'Konfirmasi Item Pembelian'
: 'Edit Item Pembelian'} : 'Edit Item Pembelian'}
</h2> </h2>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
{groupedPurchaseItems.length > 0 ? ( {groupedPurchaseItems.length > 0 ? (
<div> <div>
@@ -1140,6 +1184,7 @@ const PurchaseOrderStaffApprovalForm = ({
color='warning' color='warning'
className='px-4' className='px-4'
onClick={() => { onClick={() => {
formik.setValues(formikInitialValues);
formik.resetForm(); formik.resetForm();
setPurchaseOrderFormErrorMessage(''); setPurchaseOrderFormErrorMessage('');
onCancel?.(); onCancel?.();
@@ -1154,23 +1199,12 @@ const PurchaseOrderStaffApprovalForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting || isRejected} disabled={formik.isSubmitting || isRejected}
> >
Submit Submit
</Button> </Button>
</div> </div>
</div> </div>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
</div> </div>
</form> </form>
@@ -16,6 +16,7 @@ import SelectInput, {
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
PurchaseRequestFormSchema, PurchaseRequestFormSchema,
@@ -32,6 +33,7 @@ import {
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier'; import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
import Card from '@/components/Card'; import Card from '@/components/Card';
@@ -59,6 +61,7 @@ const PurchaseRequestForm = ({
); );
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] = const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== TYPE DEFINITIONS ===== // ===== TYPE DEFINITIONS =====
interface ProductOptionType { interface ProductOptionType {
@@ -211,6 +214,22 @@ const PurchaseRequestForm = ({
}, },
}); });
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
const { data: supplierData, isLoading: isLoadingProducts } = useSWR( const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
formik.values.supplier_id && Number(formik.values.supplier_id) > 0 formik.values.supplier_id && Number(formik.values.supplier_id) > 0
@@ -487,10 +506,29 @@ const PurchaseRequestForm = ({
</h1> </h1>
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Basic Info Card */} {/* Basic Info Card */}
<Card <Card
title='Informasi Purchase Request' title='Informasi Purchase Request'
@@ -896,7 +934,7 @@ const PurchaseRequestForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -935,17 +973,6 @@ const PurchaseRequestForm = ({
</div> </div>
)} )}
</div> </div>
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
+58
View File
@@ -6,6 +6,64 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/dashboard', link: '/dashboard',
icon: 'heroicons-outline:chart-bar-square', icon: 'heroicons-outline:chart-bar-square',
}, },
{
text: 'Daily Checklist',
link: '/daily-checklist',
icon: 'heroicons-outline:clipboard-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
submenu: [
{
text: 'Dashboard',
link: '/daily-checklist/dashboard',
icon: 'lucide:layout-dashboard',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
{
text: 'Daily Checklist',
link: '/daily-checklist/daily-checklist',
icon: 'lucide:clipboard-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
{
text: 'Daftar Daily Checklist',
link: '/daily-checklist/list-daily-checklist',
icon: 'lucide:circle-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
{
text: 'Laporan',
link: '/daily-checklist/reports',
icon: 'lucide:file-text',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
{
text: 'Master Data',
link: '/daily-checklist/master-data',
icon: 'lucide:database',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
submenu: [
{
text: 'Employee (ABK)',
link: '/daily-checklist/master-data/employee',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
{
text: 'Aktivitas',
link: '/daily-checklist/master-data/activity',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
},
],
},
],
},
{ {
text: 'Produksi', text: 'Produksi',
link: '/production', link: '/production',
+17
View File
@@ -4,6 +4,23 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
// Dashboard // Dashboard
'/dashboard/': ['lti.dashboard.list'], '/dashboard/': ['lti.dashboard.list'],
// Daily Checklist
// TODO: use real daily checklist permission name
// '/daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/dashboard/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/detail/': ['lti.daily_checklist.detail'],
// '/daily-checklist/reports/': ['lti.daily_checklist.reports'],
// '/daily-checklist/master-data/employee/': ['lti.dashboard.master_data.employee'],
// '/daily-checklist/master-data/activity/': ['lti.dashboard.master_data.activity'],
'/daily-checklist/dashboard/': ['lti.dashboard.list'],
'/daily-checklist/daily-checklist/': ['lti.dashboard.list'],
'/daily-checklist/list-daily-checklist/': ['lti.dashboard.list'],
'/daily-checklist/list-daily-checklist/detail/': ['lti.dashboard.list'],
'/daily-checklist/reports/': ['lti.dashboard.list'],
'/daily-checklist/master-data/employee/': ['lti.dashboard.list'],
'/daily-checklist/master-data/activity/': ['lti.dashboard.list'],
// Production // Production
// Production - Project Flock // Production - Project Flock
'/production/project-flock/': ['lti.production.project_flocks.list'], '/production/project-flock/': ['lti.production.project_flocks.list'],
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot='accordion' {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot='accordion-trigger'
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot='accordion-content'
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/helper';
import { buttonVariants } from '@/figma-make/components/base/button';
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/helper';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,11 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
}
export { AspectRatio };
+53
View File
@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/helper';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import { cn } from '@/lib/helper';
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'success' | 'secondary' | 'destructive' | 'outline';
}
function Badge({ className, variant = 'default', ...props }: BadgeProps) {
return (
<div
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
'bg-blue-100 text-blue-700': variant === 'default',
'bg-green-100 text-green-700': variant === 'success',
'bg-gray-100 text-gray-700': variant === 'secondary',
'bg-red-100 text-red-700': variant === 'destructive',
'border border-gray-200 text-gray-700': variant === 'outline',
},
className
)}
{...props}
/>
);
}
export { Badge };
@@ -0,0 +1,109 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/helper';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot='breadcrumb-list'
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-item'
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='breadcrumb-link'
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-page'
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-separator'
role='presentation'
aria-hidden='true'
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-ellipsis'
role='presentation'
aria-hidden='true'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className='size-4' />
<span className='sr-only'>More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/helper';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = 'Button';
export { Button, buttonVariants };
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/helper';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<h4
data-slot='card-title'
className={cn('leading-none', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<p
data-slot='card-description'
className={cn('text-muted-foreground', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6 [&:last-child]:pb-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 pb-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+241
View File
@@ -0,0 +1,241 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/helper';
import { Button } from '@/figma-make/components/base/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role='region'
aria-roledescription='carousel'
data-slot='carousel'
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className='overflow-hidden'
data-slot='carousel-content'
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel();
return (
<div
role='group'
aria-roledescription='slide'
data-slot='carousel-item'
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot='carousel-previous'
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className='sr-only'>Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot='carousel-next'
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className='sr-only'>Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
@@ -0,0 +1,33 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot='collapsible-trigger'
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot='collapsible-content'
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+177
View File
@@ -0,0 +1,177 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/figma-make/components/base/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className='overflow-hidden p-0'>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,190 @@
import * as React from 'react';
import { useState } from 'react';
import {
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
} from 'lucide-react';
import { Button } from '@/figma-make/components/base/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/figma-make/components/base/popover';
import { Input } from '@/figma-make/components/base/input';
import { Label } from '@/figma-make/components/base/label';
interface DatePickerProps {
date: string;
onDateChange: (date: string) => void;
disabled?: boolean;
placeholder?: string;
formatDisplay?: (date: string) => string;
}
export function DatePicker({
date,
onDateChange,
disabled = false,
placeholder = 'Select date',
formatDisplay,
}: DatePickerProps) {
const [open, setOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => {
const d = date ? new Date(date) : new Date();
return { year: d.getFullYear(), month: d.getMonth() };
});
const defaultFormatDisplay = (dateStr: string) => {
if (!dateStr) return placeholder;
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatDateInput = (dateStr: string) => {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const displayFormatter = formatDisplay || defaultFormatDisplay;
const navigateMonth = (direction: 'prev' | 'next') => {
const newDate = new Date(
currentMonth.year,
currentMonth.month + (direction === 'next' ? 1 : -1)
);
setCurrentMonth({ year: newDate.getFullYear(), month: newDate.getMonth() });
};
const handleDateSelect = (dateStr: string) => {
onDateChange(dateStr);
setOpen(false);
};
const handleManualInput = (value: string) => {
onDateChange(value);
setOpen(false);
};
const renderCalendar = () => {
const { year, month } = currentMonth;
const firstDay = new Date(year, month, 1).getDay();
const adjustedFirstDay = firstDay === 0 ? 6 : firstDay - 1; // Monday = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthName = new Date(year, month).toLocaleDateString('en-US', {
month: 'long',
});
const days = [];
// Empty cells before first day
for (let i = 0; i < adjustedFirstDay; i++) {
days.push(<div key={`empty-${i}`} className='h-9 w-9' />);
}
// Days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isSelected = dateStr === date;
const isToday = dateStr === new Date().toISOString().split('T')[0];
days.push(
<button
key={day}
onClick={() => handleDateSelect(dateStr)}
className={`
h-9 w-9 rounded-md text-sm font-medium transition-colors
${isSelected ? 'bg-[#0069e0] text-white hover:bg-[#0052b3]' : ''}
${!isSelected && isToday ? 'border border-[#0069e0] text-[#0069e0]' : ''}
${!isSelected && !isToday ? 'hover:bg-gray-100 text-gray-700' : ''}
`}
>
{day}
</button>
);
}
return (
<div className='p-3'>
<div className='flex items-center justify-between mb-3'>
<button
onClick={() => navigateMonth('prev')}
className='p-1 hover:bg-gray-100 rounded-md transition-colors'
>
<ChevronLeft className='w-4 h-4 text-gray-600' />
</button>
<div className='font-semibold text-sm text-gray-900'>
{monthName} {year}
</div>
<button
onClick={() => navigateMonth('next')}
className='p-1 hover:bg-gray-100 rounded-md transition-colors'
>
<ChevronRight className='w-4 h-4 text-gray-600' />
</button>
</div>
<div className='grid grid-cols-7 gap-1 mb-2'>
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((day) => (
<div
key={day}
className='h-9 w-9 flex items-center justify-center text-xs font-medium text-gray-500'
>
{day}
</div>
))}
</div>
<div className='grid grid-cols-7 gap-1'>{days}</div>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={disabled}
className='w-full justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
>
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
{date ? (
<span className='text-gray-900'>{displayFormatter(date)}</span>
) : (
<span className='text-gray-500'>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className='w-auto p-0 bg-white shadow-lg rounded-xl'
align='start'
>
{renderCalendar()}
<div className='p-3 border-t border-gray-200'>
<Label
htmlFor='manual-date'
className='text-xs text-gray-600 mb-1.5 block'
>
Atau ketik manual (YYYY-MM-DD):
</Label>
<Input
id='manual-date'
type='date'
value={date}
onChange={(e) => handleManualInput(e.target.value)}
className='text-sm border-gray-200'
/>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,352 @@
import * as React from 'react';
import { useState } from 'react';
import {
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
} from 'lucide-react';
import { Button } from '@/figma-make/components/base/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/figma-make/components/base/popover';
import { Input } from '@/figma-make/components/base/input';
interface DateRange {
from: string;
to: string;
}
interface DateRangePickerProps {
dateFrom: string;
dateTo: string;
onDateChange: (from: string, to: string) => void;
disabled?: boolean;
}
const PRESET_OPTIONS = [
{ label: 'Last 7 days', value: 'last_7_days' },
{ label: 'Last 14 Days', value: 'last_14_days' },
{ label: 'Last 30 Days', value: 'last_30_days' },
{ label: 'Last 3 months', value: 'last_3_months' },
{ label: 'Last 12 months', value: 'last_12_months' },
{ label: 'Month to date', value: 'month_to_date' },
{ label: 'Quarter to date', value: 'quarter_to_date' },
{ label: 'All time', value: 'all_time' },
{ label: 'Custom', value: 'custom' },
];
export function DateRangePicker({
dateFrom,
dateTo,
onDateChange,
disabled = false,
}: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [selectedPreset, setSelectedPreset] = useState('custom');
const [tempDateFrom, setTempDateFrom] = useState(dateFrom);
const [tempDateTo, setTempDateTo] = useState(dateTo);
const [currentMonth1, setCurrentMonth1] = useState(() => {
const date = dateFrom ? new Date(dateFrom) : new Date();
return { year: date.getFullYear(), month: date.getMonth() };
});
const [currentMonth2, setCurrentMonth2] = useState(() => {
const date = dateFrom ? new Date(dateFrom) : new Date();
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1);
return { year: nextMonth.getFullYear(), month: nextMonth.getMonth() };
});
const formatDateDisplay = (dateStr: string) => {
if (!dateStr) return 'Select date';
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const handlePresetClick = (preset: string) => {
setSelectedPreset(preset);
const today = new Date();
let from = '';
let to = today.toISOString().split('T')[0];
switch (preset) {
case 'last_7_days':
from = new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
break;
case 'last_14_days':
from = new Date(today.getTime() - 13 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
break;
case 'last_30_days':
from = new Date(today.getTime() - 29 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
break;
case 'last_3_months':
from = new Date(
today.getFullYear(),
today.getMonth() - 3,
today.getDate()
)
.toISOString()
.split('T')[0];
break;
case 'last_12_months':
from = new Date(
today.getFullYear(),
today.getMonth() - 12,
today.getDate()
)
.toISOString()
.split('T')[0];
break;
case 'month_to_date':
from = new Date(today.getFullYear(), today.getMonth(), 1)
.toISOString()
.split('T')[0];
break;
case 'quarter_to_date':
const quarter = Math.floor(today.getMonth() / 3);
from = new Date(today.getFullYear(), quarter * 3, 1)
.toISOString()
.split('T')[0];
break;
case 'all_time':
from = '2020-01-01';
break;
case 'custom':
from = tempDateFrom;
to = tempDateTo;
break;
}
setTempDateFrom(from);
setTempDateTo(to);
};
const handleSetDate = () => {
onDateChange(tempDateFrom, tempDateTo);
setOpen(false);
};
const handleCancel = () => {
setTempDateFrom(dateFrom);
setTempDateTo(dateTo);
setOpen(false);
};
const navigateMonth = (direction: 'prev' | 'next', calendar: 1 | 2) => {
if (calendar === 1) {
const newDate = new Date(
currentMonth1.year,
currentMonth1.month + (direction === 'next' ? 1 : -1)
);
setCurrentMonth1({
year: newDate.getFullYear(),
month: newDate.getMonth(),
});
} else {
const newDate = new Date(
currentMonth2.year,
currentMonth2.month + (direction === 'next' ? 1 : -1)
);
setCurrentMonth2({
year: newDate.getFullYear(),
month: newDate.getMonth(),
});
}
};
const renderCalendar = (
year: number,
month: number,
onNavigate: (dir: 'prev' | 'next') => void
) => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthName = new Date(year, month).toLocaleDateString('en-US', {
month: 'long',
});
const days = [];
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className='h-9 w-9' />);
}
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isInRange =
tempDateFrom &&
tempDateTo &&
dateStr >= tempDateFrom &&
dateStr <= tempDateTo;
const isStart = dateStr === tempDateFrom;
const isEnd = dateStr === tempDateTo;
const isToday = dateStr === new Date().toISOString().split('T')[0];
days.push(
<button
key={day}
onClick={() => {
if (selectedPreset !== 'custom') {
setSelectedPreset('custom');
}
if (!tempDateFrom || (tempDateFrom && tempDateTo)) {
setTempDateFrom(dateStr);
setTempDateTo('');
} else {
if (dateStr < tempDateFrom) {
setTempDateTo(tempDateFrom);
setTempDateFrom(dateStr);
} else {
setTempDateTo(dateStr);
}
}
}}
className={`
h-9 w-9 rounded-md text-sm font-medium transition-colors
${isStart || isEnd ? 'bg-[#0069e0] text-white hover:bg-[#0052b3]' : ''}
${isInRange && !isStart && !isEnd ? 'bg-blue-50 text-blue-900' : ''}
${!isInRange && !isStart && !isEnd ? 'hover:bg-gray-100 text-gray-700' : ''}
${isToday && !isStart && !isEnd && !isInRange ? 'border border-[#0069e0]' : ''}
`}
>
{day}
</button>
);
}
return (
<div className='p-3'>
<div className='flex items-center justify-between mb-3'>
<button
onClick={() => onNavigate('prev')}
className='p-1 hover:bg-gray-100 rounded-md transition-colors'
>
<ChevronLeft className='w-4 h-4 text-gray-600' />
</button>
<div className='font-semibold text-sm text-gray-900'>
{monthName} {year}
</div>
<button
onClick={() => onNavigate('next')}
className='p-1 hover:bg-gray-100 rounded-md transition-colors'
>
<ChevronRight className='w-4 h-4 text-gray-600' />
</button>
</div>
<div className='grid grid-cols-7 gap-1 mb-2'>
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((day) => (
<div
key={day}
className='h-9 w-9 flex items-center justify-center text-xs font-medium text-gray-500'
>
{day}
</div>
))}
</div>
<div className='grid grid-cols-7 gap-1'>{days}</div>
</div>
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={disabled}
className='justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
>
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
{dateFrom && dateTo ? (
<span className='text-gray-900'>
{formatDateDisplay(dateFrom)} {formatDateDisplay(dateTo)}
</span>
) : (
<span className='text-gray-500'>Select date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className='w-auto p-0 bg-white shadow-lg rounded-xl'
align='start'
>
<div className='flex'>
{/* Preset Sidebar */}
<div className='w-40 border-r border-gray-200 p-3 bg-gray-50'>
{PRESET_OPTIONS.map((preset) => (
<button
key={preset.value}
onClick={() => handlePresetClick(preset.value)}
className={`
w-full text-left px-3 py-2 rounded-md text-sm transition-colors mb-1
${
selectedPreset === preset.value
? 'bg-blue-50 text-blue-900 font-medium'
: 'text-gray-700 hover:bg-gray-100'
}
`}
>
{preset.label}
</button>
))}
</div>
{/* Calendar Section */}
<div>
<div className='flex'>
{renderCalendar(currentMonth1.year, currentMonth1.month, (dir) =>
navigateMonth(dir, 1)
)}
{renderCalendar(currentMonth2.year, currentMonth2.month, (dir) =>
navigateMonth(dir, 2)
)}
</div>
{/* Date Input & Actions */}
<div className='border-t border-gray-200 p-4 flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Input
type='text'
value={formatDateDisplay(tempDateFrom)}
readOnly
className='w-28 text-sm text-center border-gray-200'
/>
<span className='text-gray-400'></span>
<Input
type='text'
value={formatDateDisplay(tempDateTo)}
readOnly
className='w-28 text-sm text-center border-gray-200'
/>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={handleCancel}
className='border-gray-200'
>
Cancel
</Button>
<Button
onClick={handleSetDate}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
Set Date
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+140
View File
@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentProps<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot='dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
});
DialogOverlay.displayName = 'DialogOverlay';
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentProps<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot='dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = 'DialogContent';
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+132
View File
@@ -0,0 +1,132 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/helper';
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot='drawer-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot='drawer-content'
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className
)}
{...props}
>
<div className='bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot='drawer-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,257 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+168
View File
@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { cn } from '@/lib/helper';
import { Label } from '@/figma-make/components/base/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot='form-label'
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot='form-control'
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot='form-description'
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot='form-message'
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/helper';
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot='hover-card' {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot='hover-card-trigger' {...props} />
);
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot='hover-card-portal'>
<HoverCardPrimitive.Content
data-slot='hover-card-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center gap-1', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/helper';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
export { Input };
+24
View File
@@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/helper';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };
+276
View File
@@ -0,0 +1,276 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot='menubar'
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot='menubar-trigger'
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot='menubar-content'
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<MenubarPrimitive.Item
data-slot='menubar-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot='menubar-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot='menubar-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot='menubar-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot='menubar-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='menubar-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot='menubar-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto h-4 w-4' />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot='menubar-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
@@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { cn } from '@/lib/helper';
import { Button } from '@/figma-make/components/base/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/figma-make/components/base/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/figma-make/components/base/popover';
import { Badge } from '@/figma-make/components/base/badge';
export interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
onSearchChange?: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export function MultiSelect({
options,
selected,
onChange,
onSearchChange,
placeholder = 'Select items...',
className,
disabled,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleUnselect = (item: string) => {
onChange(selected.filter((i) => i !== item));
};
const handleSelect = (item: string) => {
if (selected.includes(item)) {
handleUnselect(item);
} else {
onChange([...selected, item]);
}
};
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className={cn(
'w-full justify-between items-center h-auto min-h-9 py-2 px-3',
className
)}
disabled={disabled}
>
<div className='flex flex-wrap gap-1'>
{selected.length > 0 ? (
selected.map((item) => {
const option = options.find((o) => o.value === item);
return (
<Badge variant='outline' key={item} className='mr-1 mb-1'>
{option?.label || item}
<div
className='ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(item);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleUnselect(item);
}}
>
<X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
</div>
</Badge>
);
})
) : (
<span className='text-muted-foreground font-normal'>
{placeholder}
</span>
)}
</div>
<ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-full p-0' align='start'>
<Command>
<CommandInput
placeholder={`Search ${placeholder.toLowerCase()}...`}
onValueChange={onSearchChange}
/>
<CommandEmpty>No item found.</CommandEmpty>
<CommandList className='max-h-[300px] overflow-y-auto'>
<CommandGroup className='overflow-visible'>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleSelect(option.value)}
className='cursor-pointer'
>
<Check
className={cn(
'mr-2 h-4 w-4',
selected.includes(option.value)
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,168 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot='navigation-menu'
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot='navigation-menu-list'
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot='navigation-menu-item'
className={cn('relative', className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1'
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot='navigation-menu-trigger'
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className='relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180'
aria-hidden='true'
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot='navigation-menu-content'
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
'absolute top-full left-0 isolate z-50 flex justify-center'
)}
>
<NavigationMenuPrimitive.Viewport
data-slot='navigation-menu-viewport'
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot='navigation-menu-link'
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot='navigation-menu-indicator'
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className
)}
{...props}
>
<div className='bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md' />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};
@@ -0,0 +1,127 @@
import * as React from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/helper';
import { Button, buttonVariants } from '@/figma-make/components/base/button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role='navigation'
aria-label='pagination'
data-slot='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='pagination-content'
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot='pagination-item' {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot='pagination-link'
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className='hidden sm:block'>Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className='hidden sm:block'>Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot='pagination-ellipsis'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className='size-4' />
<span className='sr-only'>More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/helper';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/helper';
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { CircleIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot='radio-group'
className={cn('grid gap-3', className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot='radio-group-item'
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot='radio-group-indicator'
className='relative flex items-center justify-center'
>
<CircleIcon className='fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2' />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { GripVerticalIcon } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/helper';
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot='resizable-panel-group'
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot='resizable-panel' {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot='resizable-handle'
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className='bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border'>
<GripVerticalIcon className='size-2.5' />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/helper';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot='scroll-area'
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot='scroll-area-viewport'
className='focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1'
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot='scroll-area-scrollbar'
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot='scroll-area-thumb'
className='bg-border relative flex-1 rounded-full'
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+185
View File
@@ -0,0 +1,185 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot='select' {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot='select-group' {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot='select-value' {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot='select-trigger'
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className='size-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot='select-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[300px] min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot='select-label'
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot='select-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className='absolute right-2 flex size-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot='select-separator'
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot='select-scroll-up-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className='size-4' />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot='select-scroll-down-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className='size-4' />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/helper';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };
+139
View File
@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/helper';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot='sheet' {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot='sheet-trigger' {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot='sheet-close' {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot='sheet-portal' {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot='sheet-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot='sheet-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none'>
<XIcon className='size-4' />
<span className='sr-only'>Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot='sheet-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot='sheet-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+726
View File
@@ -0,0 +1,726 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from '@/figma-make/components/base/use-mobile';
import { cn } from '@/lib/helper';
import { Button } from '@/figma-make/components/base/button';
import { Input } from '@/figma-make/components/base/input';
import { Separator } from '@/figma-make/components/base/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/figma-make/components/base/sheet';
import { Skeleton } from '@/figma-make/components/base/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/figma-make/components/base/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot='sidebar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot='sidebar-gap'
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
/>
<div
data-slot='sidebar-container'
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar='sidebar'
data-slot='sidebar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className='sr-only'>Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title='Toggle Sidebar'
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='sidebar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,13 @@
import { cn } from '@/lib/helper';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='skeleton'
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
);
}
export { Skeleton };
+63
View File
@@ -0,0 +1,63 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/helper';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot='slider'
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot='slider-track'
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
)}
>
<SliderPrimitive.Range
data-slot='slider-range'
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot='slider-thumb'
key={index}
className='border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50'
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,49 @@
import * as React from 'react';
import { cn } from '@/lib/helper';
export interface StatusBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'active' | 'pending' | 'inactive';
children: React.ReactNode;
}
const StatusBadge = React.forwardRef<HTMLDivElement, StatusBadgeProps>(
({ className, variant = 'pending', children, ...props }, ref) => {
const variants = {
active: 'bg-green-100/50 text-gray-900',
pending: 'bg-gray-100/80 text-gray-900',
inactive: 'bg-red-100/50 text-gray-900',
};
const dotColors = {
active: '#008000',
pending: '#D9D9D9',
inactive: '#FF3A3A',
};
return (
<div
ref={ref}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-lg border border-black/5',
variants[variant],
className
)}
{...props}
>
<svg
className='w-3 h-3'
fill='none'
preserveAspectRatio='none'
viewBox='0 0 12 12'
>
<circle cx='6' cy='6' r='6' fill={dotColors[variant]} />
</svg>
<span className='text-xs font-medium leading-6'>{children}</span>
</div>
);
}
);
StatusBadge.displayName = 'StatusBadge';
export { StatusBadge };
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/helper';
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot='switch'
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className={cn(
'bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+116
View File
@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/helper';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot='table-container'
className='relative w-full overflow-x-auto'
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot='table-header'
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot='table-body'
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot='table-footer'
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot='table-row'
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot='table-head'
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot='table-caption'
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+66
View File
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/helper';
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot='tabs'
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot='tabs-list'
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex',
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot='tabs-trigger'
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot='tabs-content'
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/helper';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };

Some files were not shown because too many files have changed in this diff Show More