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

This commit is contained in:
randy-ar
2026-01-10 08:10:18 +07:00
110 changed files with 18387 additions and 816 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>
); );
+7
View File
@@ -0,0 +1,7 @@
import FinanceTabs from '@/components/pages/report/finance/FinanceTabs';
const Finance = () => {
return <FinanceTabs />;
};
export default Finance;
@@ -52,11 +52,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
/> />
), ),
}, },
{ // {
id: 'penjualan', // id: 'penjualan',
label: 'Penjualan', // label: 'Penjualan',
content: <SalesReportTable initialValues={salesData} />, // 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
) )
: '-'; : '-';
}, },
@@ -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)}
@@ -1589,8 +1687,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
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;
} }
@@ -1747,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 &&
@@ -1760,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>
</> </>
@@ -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' && (
@@ -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>
)} )}
@@ -151,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,
@@ -166,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}
/>
); );
}; };
@@ -251,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(() => {
@@ -328,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]);
@@ -374,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();
@@ -433,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);
@@ -442,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)
@@ -633,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();
@@ -896,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
@@ -1140,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,14 +684,15 @@ 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( const invalidFiles = files.filter(
(file) => file.size > 2 * 1024 * 1024 (file) => file.size > 5 * 1024 * 1024
); );
if (invalidFiles.length > 0) { if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 2 MB!'); toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = ''; e.target.value = '';
return; return;
} }
@@ -726,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?.();
@@ -741,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!'),
@@ -395,9 +390,9 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
.of( .of(
Yup.mixed<File>() Yup.mixed<File>()
.required('Dokumen surat jalan wajib diupload!') .required('Dokumen surat jalan wajib diupload!')
.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;
}) })
) )
@@ -415,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: '',
@@ -436,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: '',
@@ -447,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);
@@ -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 =====
@@ -652,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>
@@ -1164,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>
@@ -0,0 +1,23 @@
'use client';
import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
const FinanceTabs = () => {
const tabs = [
{
id: '1',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default FinanceTabs;
@@ -0,0 +1,425 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
supplierInfo: {
fontSize: 9,
marginBottom: 5,
color: '#333333',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
summaryRow: {
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
});
interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[];
}
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
return (
<Document>
{params.data.map((customerReport, customerIndex) => (
<Page
key={customerIndex}
size='A4'
orientation='landscape'
style={pdfStyles.page}
>
{/* Title and Customer Info */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Kontrol Pembayaran Customer
</Text>
<Text style={pdfStyles.supplierTitle}>
{customerReport.customer.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
{customerReport.customer_address || ''}
</Text>
<Text style={pdfStyles.supplierInfo}>
NPWP: {customerReport.customer_npwp || '-'}
</Text>
{customerReport.summary && (
<Text style={pdfStyles.supplierInfo}>
Total Saldo Piutang:{' '}
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
)}
</View>
{/* Table */}
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl DO/Bayar</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl Realisasi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Referensi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>No. Polisi</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Berat (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>AVG</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>CN</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Akhir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>PPN (%)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Pembayaran</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Saldo Piutang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Ket</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Pengambilan</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Sales</Text>
</View>
</View>
{/* Table Body */}
{customerReport.rows.map((item, index) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < customerReport.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.do_date ? formatDate(item.do_date, 'DD MMM YY') : '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.realization_date
? formatDate(item.realization_date, 'DD MMM YY')
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.8 }]}>
<Text>{formatNumber(item.aging)} hari</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.reference || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.vehicle_plate || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.average_weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.credit_note)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.final_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.ppn)}%</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.total)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.payment)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.accounts_receivable)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.notes || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.sales_marketing || '-'}</Text>
</View>
</View>
))}
{/* Summary Row */}
{customerReport.summary && (
<View style={[pdfStyles.tableRow, pdfStyles.summaryRow]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(customerReport.summary.total_qty)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(customerReport.summary.total_weight)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(
customerReport.summary.total_initial_amount
)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatCurrency(customerReport.summary.total_credit_note)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_final_amount)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_grand_amount)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_payment)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellLast, { flex: 1.5 }]}>
<Text></Text>
</View>
</View>
)}
</View>
</Page>
))}
</Document>
);
};
export const generateCustomerPaymentPDF = async (
params: CustomerPaymentExportPDFParams
): Promise<void> => {
const PDFDocument = createPDFDocument(params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-kontrol-pembayaran-customer-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,115 @@
'use client';
import * as XLSX from 'xlsx';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
interface CustomerPaymentExportExcelParams {
data: CustomerPaymentReport[];
}
export const generateCustomerPaymentExcel = (
params: CustomerPaymentExportExcelParams
): void => {
if (!params.data || params.data.length === 0) {
return;
}
const workbook = XLSX.utils.book_new();
params.data.forEach((customerReport) => {
const customerData = customerReport.rows;
const customerName = customerReport.customer.name || 'Unknown Customer';
const excelData: { [key: string]: string | number }[] = customerData.map(
(item, index) => ({
No: index + 1,
'Tanggal DO/Bayar': item.do_date
? formatDate(item.do_date, 'DD MMM YYYY')
: '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
: '',
Aging: formatNumber(item.aging || 0),
Referensi: item.reference || '',
'Nomor Polisi': item.vehicle_plate || '',
'Ekor/Qty': formatNumber(item.qty || 0),
'Berat (Kg)': formatNumber(item.weight || 0),
AVG: formatNumber(item.average_weight || 0),
'Harga Awal': formatCurrency(item.price || 0),
CN: formatCurrency(item.credit_note || 0),
'Harga Akhir': formatCurrency(item.final_price || 0),
'PPN (%)': formatNumber(item.ppn || 0),
Total: formatCurrency(item.total || 0),
Pembayaran: formatCurrency(item.payment || 0),
'Saldo Piutang': formatCurrency(item.accounts_receivable || 0),
Keterangan: item.notes || '',
Pengambilan: item.pickup_info || '',
'Sales/Marketing': item.sales_marketing || '',
})
);
if (customerReport.summary) {
excelData.push({
No: 'Total',
'Tanggal DO/Bayar': '',
'Tanggal Realisasi': '',
Aging: '',
Referensi: '',
'Nomor Polisi': '',
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
AVG: '',
'Harga Awal': formatCurrency(
customerReport.summary.total_initial_amount || 0
),
CN: formatCurrency(customerReport.summary.total_credit_note || 0),
'Harga Akhir': formatCurrency(
customerReport.summary.total_final_amount || 0
),
'PPN (%)': '',
Total: formatCurrency(customerReport.summary.total_grand_amount || 0),
Pembayaran: formatCurrency(customerReport.summary.total_payment || 0),
'Saldo Piutang': formatCurrency(
customerReport.summary.total_accounts_receivable || 0
),
Keterangan: '',
Pengambilan: '',
'Sales/Marketing': '',
});
}
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Tanggal DO/Bayar
{ wch: 15 }, // Tanggal Realisasi
{ wch: 8 }, // Aging
{ wch: 12 }, // Referensi
{ wch: 15 }, // Nomor Polisi
{ wch: 10 }, // Ekor/Qty
{ wch: 12 }, // Berat
{ wch: 10 }, // AVG
{ wch: 15 }, // Harga Awal
{ wch: 10 }, // CN
{ wch: 15 }, // Harga Akhir
{ wch: 10 }, // PPN
{ wch: 15 }, // Total
{ wch: 15 }, // Pembayaran
{ wch: 15 }, // Saldo Piutang
{ wch: 20 }, // Keterangan
{ wch: 15 }, // Pengambilan
{ wch: 20 }, // Sales/Marketing
];
worksheet['!cols'] = colWidths;
const sheetName =
customerName.length > 31 ? customerName.substring(0, 31) : customerName;
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
const filename = `laporan-kontrol-pembayaran-customer-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
};
@@ -0,0 +1,717 @@
import { useState, useMemo, useCallback } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import {
CustomerPaymentReport,
CustomerPaymentSummary,
} from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import Modal from '@/components/Modal';
import { useModal } from '@/components/Modal';
import toast from 'react-hot-toast';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
const CustomerPaymentTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]);
const [filterSales, setFilterSales] = useState<OptionType[]>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal();
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo(
() => [
{ value: 'Sales A', label: 'Sales A' },
{ value: 'Sales B', label: 'Sales B' },
{ value: 'Sales C', label: 'Sales C' },
// TODO: Fetch sales options from API
],
[]
);
const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }],
[]
);
// ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterCustomer([]);
setFilterSales([]);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
}, []);
const handleApplyFilters = useCallback(() => {
const errors: Record<string, string> = {};
if (!filterStartDate) {
errors.start_date = 'Tanggal mulai wajib diisi';
}
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi';
}
setFilterErrors(errors);
if (Object.keys(errors).length === 0) {
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}
}, [filterModal, filterStartDate, filterEndDate]);
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
customer_id:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
}
: null,
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
const meta =
isResponseSuccess(customerPayment) && customerPayment?.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
> => {
const params = {
customer_id:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [filterCustomer, filterSales, filterStartDate, filterEndDate]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
generateCustomerPaymentExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [customerPaymentExport]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateCustomerPaymentPDF({ data: allDataForExport });
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
const tableColumns: ColumnDef<CustomerPaymentReport['rows'][0]>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'do_date_or_payment_date',
header: 'Tanggal DO/Bayar',
accessorKey: 'do_date',
cell: (props) => {
const value = props.row.original.do_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'realization_date',
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
cell: (props) => {
const value = props.row.original.realization_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'aging',
header: 'Aging',
accessorKey: 'aging',
cell: (props) => {
const value = props.row.original.aging;
return <div className='text-center'>{formatNumber(value)} hari</div>;
},
},
{
id: 'reference',
header: 'Referensi',
accessorKey: 'reference',
cell: (props) => {
const value = props.row.original.reference;
return value || '-';
},
},
{
id: 'vehicle_plate',
header: 'Nomor Polisi',
accessorKey: 'vehicle_plate',
cell: (props) => {
const value = props.row.original.vehicle_plate;
return value || '-';
},
},
{
id: 'qty',
header: 'Ekor/Qty',
accessorKey: 'qty',
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_qty) || '-'}
</div>
),
},
{
id: 'weight',
header: 'Berat (Kg)',
accessorKey: 'weight',
cell: (props) => {
const value = props.row.original.weight;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_weight) || '-'}
</div>
),
},
{
id: 'average_weight',
header: 'AVG',
accessorKey: 'average_weight',
cell: (props) => {
const value = props.row.original.average_weight;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'price',
header: 'Harga Awal',
accessorKey: 'price',
cell: (props) => {
const value = props.row.original.price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_initial_amount) || '-'}
</div>
),
},
{
id: 'credit_note',
header: 'CN',
accessorKey: 'credit_note',
cell: (props) => {
const value = props.row.original.credit_note;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_credit_note) || '-'}
</div>
),
},
{
id: 'final_price',
header: 'Harga Akhir',
accessorKey: 'final_price',
cell: (props) => {
const value = props.row.original.final_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_final_amount) || '-'}
</div>
),
},
{
id: 'ppn',
header: 'PPN (%)',
accessorKey: 'ppn',
cell: (props) => {
const value = props.row.original.ppn;
return <div className='text-right'>{formatNumber(value)}%</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'total',
header: 'Total',
accessorKey: 'total',
cell: (props) => {
const value = props.row.original.total;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_grand_amount) || '-'}
</div>
),
},
{
id: 'payment',
header: 'Pembayaran',
accessorKey: 'payment',
cell: (props) => {
const value = props.row.original.payment;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_payment) || '-'}
</div>
),
},
{
id: 'accounts_receivable',
header: 'Saldo Piutang',
accessorKey: 'accounts_receivable',
cell: (props) => {
const value = props.row.original.accounts_receivable;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_accounts_receivable) || '-'}
</div>
),
},
{
id: 'notes',
header: 'Keterangan',
accessorKey: 'notes',
cell: (props) => {
const value = props.row.original.notes;
return value || '-';
},
},
{
id: 'pickup_info',
header: 'Pengambilan',
accessorKey: 'pickup_info',
cell: (props) => {
const value = props.row.original.pickup_info;
return value || '-';
},
},
{
id: 'sales_marketing',
header: 'Sales/Marketing',
accessorKey: 'sales_marketing',
cell: (props) => {
const value = props.row.original.sales_marketing;
return value || '-';
},
},
];
return tableColumns;
};
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle='Laporan > Kontrol Pembayaran Customer'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button variant='outline' onClick={filterModal.openModal}>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
</Button>
<Dropdown
trigger={
<Button variant='outline' isLoading={isAnyExportLoading}>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
height={18}
/>
Export
</Button>
}
align='end'
>
<Menu>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
}}
>
<div className='space-y-6'>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='space-y-4 px-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
<div>
<DateInput
label='Tanggal'
name='start_date'
value={filterStartDate}
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>
<div>
<DateInput
label=' '
name='end_date'
value={filterEndDate}
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
label='Customer'
placeholder='Pilih Customer'
isMulti
options={customerOptions}
value={filterCustomer}
onChange={(val) => {
setFilterCustomer(
Array.isArray(val) ? val : val ? [val] : []
);
}}
isLoading={isLoadingCustomers}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<SelectInput
label='Sales'
placeholder='Pilih Sales'
isMulti
options={salesOptions}
value={filterSales}
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={dataTypeOptions[0]}
isDisabled={true}
className={{ wrapper: 'w-full' }}
/>
</div>
</div>
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
<Button
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilters}
>
Reset Filter
</Button>
<Button
className='me-4 min-w-36 rounded-lg'
onClick={handleApplyFilters}
>
Apply Filter
</Button>
</div>
</div>
</Modal>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan klik tombol Filter untuk mengatur filter dan menampilkan
data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
data.map((customerReport) => {
const summary = customerReport.summary || {
total_qty: 0,
total_weight: 0,
total_initial_amount: 0,
total_credit_note: 0,
total_final_amount: 0,
total_ppn: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
};
const totalAccountsReceivable = summary.total_accounts_receivable;
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`NPWP: ${customerReport.customer_npwp || '-'} | ${customerReport.customer_address || ''}\nSaldo Piutang: ${formatCurrency(totalAccountsReceivable)}`}
className={{ wrapper: 'w-full' }}
variant='bordered'
collapsible={true}
>
<Table
data={customerReport.rows}
columns={tableColumns}
pageSize={10}
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
);
})
)}
</Card>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
export default CustomerPaymentTab;
+62
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',
@@ -69,6 +127,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/report', link: '/report',
icon: 'mdi:chart-box-outline', icon: 'mdi:chart-box-outline',
submenu: [ submenu: [
{
text: 'Keuangan',
link: '/report/finance',
},
{ {
text: 'Logistik & Persediaan', text: 'Logistik & Persediaan',
link: '/report/logistic-stock', link: '/report/logistic-stock',
+18
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'],
@@ -100,6 +117,7 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/report/expense/': ['lti.repport.expense.list'], '/report/expense/': ['lti.repport.expense.list'],
'/report/marketing/': ['lti.repport.delivery.list'], '/report/marketing/': ['lti.repport.delivery.list'],
'/report/production-result/': ['lti.repport.production_result.list'], '/report/production-result/': ['lti.repport.production_result.list'],
'/report/finance/': ['lti.repport.finance.list'],
// Inventory // Inventory
'/inventory/adjustment/': ['lti.inventory.list'], '/inventory/adjustment/': ['lti.inventory.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 };
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/helper';
import { toggleVariants } from '@/figma-make/components/base/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot='toggle-group'
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot='toggle-group-item'
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
+47
View File
@@ -0,0 +1,47 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/helper';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot='toggle'
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/helper';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot='tooltip' {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,712 @@
'use client';
import { useState, useEffect } from 'react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/figma-make/components/base/card';
import { Label } from '@/figma-make/components/base/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import { Input } from '@/figma-make/components/base/input';
import { Badge } from '@/figma-make/components/base/badge';
import {
Calendar as CalendarIcon,
Users,
AlertCircle,
Info,
} from 'lucide-react';
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts';
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
import { toast } from 'sonner';
interface EmployeePerformance {
employee_id: string;
employee_name: string;
kandang_id: string;
kandang_name: string;
total_activities_in_category: number; // Total aktivitas di kategori
completed_activities: number; // Aktivitas yang sudah di-check
completion_rate: number;
last_activity_date: string | null;
color: string; // Color based on kandang
}
interface Kandang {
id: string;
name: string;
}
interface Category {
id: string;
name: string;
}
interface ChecklistKandang {
id: string;
date: string;
kandang_id: string;
category: string;
kandang: {
id: string;
name: string;
} | null;
}
interface AssignmentEmployee {
id: string;
task_id: string;
employee_id: string;
checked: boolean;
updated_at: string;
employee: {
id: string;
name: string;
} | null;
}
const KANDANG_COLORS = [
'#0069e0', // Blue (primary)
'#10B981', // Green
'#F59E0B', // Amber
'#EF4444', // Red
'#8B5CF6', // Violet
'#EC4899', // Pink
'#14B8A6', // Teal
'#F97316', // Orange
];
const CATEGORY_LABELS: { [key: string]: string } = {
pullet_open: 'Pullet Open',
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
};
export function Dashboard() {
const [loading, setLoading] = useState(false);
const [employeePerformance, setEmployeePerformance] = useState<
EmployeePerformance[]
>([]);
// Master data
const [kandangList, setKandangList] = useState<Kandang[]>([]);
const [categoryList, setCategoryList] = useState<Category[]>([]);
// Filters
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [kandangFilter, setKandangFilter] = useState('ALL');
const [categoryFilter, setCategoryFilter] = useState('ALL');
// Color mapping for kandang
const [kandangColorMap, setKandangColorMap] = useState<{
[key: string]: string;
}>({});
useEffect(() => {
fetchMasterData();
}, []);
useEffect(() => {
// Only fetch when date filters are set
if (dateFrom && dateTo) {
fetchEmployeePerformance();
} else {
setEmployeePerformance([]);
}
}, [dateFrom, dateTo, kandangFilter, categoryFilter]);
const fetchMasterData = async () => {
if (!isSupabaseConfigured()) return;
try {
// Fetch kandang
const { data: kandangData, error: kandangError } = await supabase
.from('kandang')
.select('id, name')
.order('name', { ascending: true });
if (kandangError) {
console.error('Error fetching kandang:', kandangError);
} else {
setKandangList(kandangData || []);
// Create color mapping
const colorMap: { [key: string]: string } = {};
(kandangData || []).forEach((k, index) => {
colorMap[k.id] = KANDANG_COLORS[index % KANDANG_COLORS.length];
});
setKandangColorMap(colorMap);
}
// Set categories from CATEGORY_LABELS (hardcoded list)
const categories: Category[] = Object.keys(CATEGORY_LABELS).map((id) => ({
id,
name: CATEGORY_LABELS[id],
}));
setCategoryList(categories);
} catch (error) {
console.error('Error fetching master data:', error);
}
};
const fetchEmployeePerformance = async () => {
if (!isSupabaseConfigured() || !dateFrom || !dateTo) {
return;
}
try {
setLoading(true);
// Step 1: Get all checklists in date range + filters
let checklistQuery = supabase
.from('daily_checklists')
.select(
`
id,
date,
kandang_id,
category,
kandang:kandang_id (
id,
name
)
`
)
.gte('date', dateFrom)
.lte('date', dateTo);
if (kandangFilter !== 'ALL') {
checklistQuery = checklistQuery.eq('kandang_id', kandangFilter);
}
if (categoryFilter !== 'ALL') {
checklistQuery = checklistQuery.eq('category', categoryFilter);
}
const { data: checklists, error: checklistError } = await checklistQuery;
if (checklistError) {
console.error('Error fetching checklists:', checklistError);
toast.error('Gagal memuat data checklist');
return;
}
if (!checklists || checklists.length === 0) {
setEmployeePerformance([]);
return;
}
const checklistsData = checklists as unknown as ChecklistKandang[];
// Step 2: Get all tasks from these checklists
const checklistIds = checklistsData.map((c) => c.id);
const { data: tasks, error: tasksError } = await supabase
.from('daily_checklist_activity_tasks')
.select('id, checklist_id')
.in('checklist_id', checklistIds);
if (tasksError) {
console.error('Error fetching tasks:', tasksError);
return;
}
if (!tasks || tasks.length === 0) {
setEmployeePerformance([]);
return;
}
const taskIds = tasks.map((t) => t.id);
// Step 3: Get all assignments for these tasks
const { data: assignments, error: assignmentsError } = await supabase
.from('daily_checklist_activity_task_assignments')
.select(
`
id,
task_id,
employee_id,
checked,
updated_at,
employee:employee_id (
id,
name
)
`
)
.in('task_id', taskIds);
if (assignmentsError) {
console.error('Error fetching assignments:', assignmentsError);
return;
}
if (!assignments || assignments.length === 0) {
setEmployeePerformance([]);
return;
}
const assignmentsData = assignments as unknown as AssignmentEmployee[];
// Step 4: Calculate total activities in selected category (if filtered)
let totalActivitiesInCategory = 0;
if (categoryFilter !== 'ALL') {
// Get total activities from master data for this category
const { data: phases } = await supabase
.from('phases')
.select('id')
.eq('category_id', categoryFilter);
if (phases && phases.length > 0) {
const phaseIds = phases.map((p) => p.id);
const { count } = await supabase
.from('activities')
.select('*', { count: 'exact', head: true })
.in('phase_id', phaseIds);
totalActivitiesInCategory = count || 0;
}
}
// Step 5: Group by employee and calculate performance
const employeeMap = new Map<
string,
{
employee_id: string;
employee_name: string;
kandang_id: string;
kandang_name: string;
completed_count: number;
total_count: number;
last_activity_date: string | null;
}
>();
assignmentsData.forEach((assignment) => {
const task = tasks.find((t) => t.id === assignment.task_id);
if (!task) return;
const checklist = checklistsData.find(
(c) => c.id === task.checklist_id
);
if (!checklist) return;
const employeeId = assignment.employee_id;
const employeeName = assignment.employee?.name || 'Unknown';
const kandangId = checklist.kandang_id;
const kandangName = checklist.kandang?.name || 'Unknown';
if (!employeeMap.has(employeeId)) {
employeeMap.set(employeeId, {
employee_id: employeeId,
employee_name: employeeName,
kandang_id: kandangId,
kandang_name: kandangName,
completed_count: 0,
total_count: 0,
last_activity_date: null,
});
}
const empData = employeeMap.get(employeeId)!;
empData.total_count += 1;
if (assignment.checked) {
empData.completed_count += 1;
}
// Update last activity date
if (assignment.updated_at) {
if (
!empData.last_activity_date ||
assignment.updated_at > empData.last_activity_date
) {
empData.last_activity_date = assignment.updated_at;
}
}
});
// Step 6: Convert to array and add calculated fields
const performanceData: EmployeePerformance[] = Array.from(
employeeMap.values()
).map((emp) => {
// Use total activities in category if category is selected, otherwise use employee's assigned count
const totalActivities =
categoryFilter !== 'ALL' && totalActivitiesInCategory > 0
? totalActivitiesInCategory
: emp.total_count;
return {
employee_id: emp.employee_id,
employee_name: emp.employee_name,
kandang_id: emp.kandang_id,
kandang_name: emp.kandang_name,
total_activities_in_category: totalActivities,
completed_activities: emp.completed_count,
completion_rate:
totalActivities > 0
? Math.round((emp.completed_count / totalActivities) * 100)
: 0,
last_activity_date: emp.last_activity_date,
color: kandangColorMap[emp.kandang_id] || '#0069e0',
};
});
// Sort by employee name
performanceData.sort((a, b) =>
a.employee_name.localeCompare(b.employee_name)
);
setEmployeePerformance(performanceData);
} catch (error) {
console.error('Error fetching employee performance:', error);
toast.error('Terjadi kesalahan saat memuat data');
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const hasFilters = dateFrom && dateTo;
// Prepare chart data
const chartData = employeePerformance.map((emp) => ({
name: emp.employee_name,
completed: emp.completed_activities,
remaining: emp.total_activities_in_category - emp.completed_activities,
total: emp.total_activities_in_category,
color: emp.color,
kandang: emp.kandang_name,
}));
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>Dashboard</h1>
<p className='text-sm text-gray-600 mt-1'>
Performance tracking per ABK
</p>
</div>
{/* Filters Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
<CardHeader>
<CardTitle className='text-lg'>Filter</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
<div>
<Label>
Periode Tanggal <span className='text-red-500'>*</span>
</Label>
<div className='mt-1.5'>
<DateRangePicker
dateFrom={dateFrom}
dateTo={dateTo}
onDateChange={(from, to) => {
setDateFrom(from);
setDateTo(to);
}}
/>
</div>
</div>
<div>
<Label htmlFor='kandang-filter-dashboard'>Kandang</Label>
<div className='mt-1.5'>
<Select
value={kandangFilter}
onValueChange={setKandangFilter}
>
<SelectTrigger
id='kandang-filter-dashboard'
className='border-gray-200'
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangList.map((kandang) => (
<SelectItem key={kandang.id} value={kandang.id}>
{kandang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor='category-filter-dashboard'>Kategori</Label>
<div className='mt-1.5'>
<Select
value={categoryFilter}
onValueChange={setCategoryFilter}
>
<SelectTrigger
id='category-filter-dashboard'
className='border-gray-200'
>
<SelectValue placeholder='Semua Kategori' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ALL'>Semua Kategori</SelectItem>
{categoryList.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Performance Chart Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
<CardHeader>
<div className='flex items-center gap-2'>
<CardTitle className='text-lg'>
Performance Overview - Aktivitas per ABK
</CardTitle>
<Info className='w-4 h-4 text-gray-400' />
</div>
<p className='text-sm text-gray-500 mt-1'>
Aktivitas yang telah diselesaikan vs total aktivitas di kategori
</p>
</CardHeader>
<CardContent>
{!hasFilters ? (
// Empty state - no filters
<div className='flex flex-col items-center justify-center py-16 text-center'>
<AlertCircle className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Pilih Filter Terlebih Dahulu
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Silakan pilih{' '}
<span className='font-semibold'>Tanggal Dari</span> dan{' '}
<span className='font-semibold'>Tanggal Sampai</span> untuk
melihat performance ABK.
</p>
</div>
) : loading ? (
<div className='text-center py-16 text-gray-500'>
Memuat data...
</div>
) : employeePerformance.length === 0 ? (
<div className='flex flex-col items-center justify-center py-16 text-center'>
<Users className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Tidak Ada Data
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Tidak ada data aktivitas ABK pada periode yang dipilih.
</p>
</div>
) : (
<ResponsiveContainer width='100%' height={400}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 80 }}
>
<CartesianGrid strokeDasharray='3 3' stroke='#E5E7EB' />
<XAxis
dataKey='name'
angle={-45}
textAnchor='end'
height={100}
tick={{ fill: '#6B7280', fontSize: 12 }}
/>
<YAxis tick={{ fill: '#6B7280', fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #E5E7EB',
borderRadius: '8px',
padding: '12px',
}}
labelStyle={{ fontWeight: 600, marginBottom: '8px' }}
formatter={(value, name) => {
if (name === 'completed')
return [value, 'Aktivitas Selesai'];
if (name === 'remaining')
return [value, 'Aktivitas Tersisa'];
return [value, name];
}}
/>
<Legend
wrapperStyle={{ paddingTop: '20px' }}
formatter={(value: string) => {
if (value === 'completed') return 'Aktivitas Selesai';
if (value === 'remaining') return 'Aktivitas Tersisa';
return value;
}}
/>
<Bar
dataKey='completed'
stackId='a'
fill='#10B981'
radius={[0, 0, 0, 0]}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-completed-${index}`}
fill={entry.color}
/>
))}
</Bar>
<Bar
dataKey='remaining'
stackId='a'
fill='#E5E7EB'
radius={[4, 4, 0, 0]}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-remaining-${index}`}
fill={`${entry.color}33`}
opacity={0.3}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Employee Tracking Table */}
{hasFilters && employeePerformance.length > 0 && (
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardHeader>
<CardTitle className='text-lg'>Tracking ABK</CardTitle>
<p className='text-sm text-gray-500 mt-1'>
Detail performance masing-masing ABK
</p>
</CardHeader>
<CardContent>
<div className='overflow-x-auto'>
<table className='w-full border border-gray-200 rounded-lg'>
<thead>
<tr className='bg-gray-50 border-b border-gray-200'>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Nama ABK
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Kandang
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Total Aktivitas
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Aktivitas Selesai
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Aktivitas Tersisa
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Completion Rate
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Last Activity
</th>
</tr>
</thead>
<tbody>
{employeePerformance.map((emp, index) => (
<tr
key={emp.employee_id}
className={
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
}
>
<td className='py-3 px-4 text-sm text-gray-900 font-medium'>
{emp.employee_name}
</td>
<td className='py-3 px-4'>
<Badge
style={{
backgroundColor: `${emp.color}15`,
color: emp.color,
borderColor: `${emp.color}30`,
}}
className='border'
>
{emp.kandang_name}
</Badge>
</td>
<td className='py-3 px-4 text-center text-sm text-gray-900'>
{emp.total_activities_in_category}
</td>
<td className='py-3 px-4 text-center text-sm font-semibold text-green-700'>
{emp.completed_activities}
</td>
<td className='py-3 px-4 text-center text-sm text-gray-600'>
{emp.total_activities_in_category -
emp.completed_activities}
</td>
<td className='py-3 px-4 text-center'>
<div className='flex items-center justify-center gap-2'>
<div className='w-24 bg-gray-200 rounded-full h-2'>
<div
className='h-2 rounded-full transition-all'
style={{
width: `${emp.completion_rate}%`,
backgroundColor: emp.color,
}}
/>
</div>
<span className='text-sm text-gray-700 font-medium'>
{emp.completion_rate}%
</span>
</div>
</td>
<td className='py-3 px-4 text-sm text-gray-600'>
{formatDate(emp.last_activity_date)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
@@ -0,0 +1,762 @@
'use client';
import { useState } from 'react';
import { Eye, CheckCircle, XCircle, Search, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge';
import { Label } from '@/figma-make/components/base/label';
import { Textarea } from '@/figma-make/components/base/textarea';
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/figma-make/components/base/dialog';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import useSWR from 'swr';
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Table from '@/components/Table';
import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
import { cn } from '@/lib/helper';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
interface Kandang {
id: string;
name: string;
}
const STATUS_OPTIONS = [
{ value: 'ALL', label: 'Semua Status' },
{ value: 'DRAFT', label: 'Draft' },
{ value: 'SUBMITTED', label: 'Submitted' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' },
];
const CATEGORY_LABELS: { [key: string]: string } = {
pullet_open: 'Pullet Open',
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
};
export function ListDailyChecklistContent() {
const router = useRouter();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
date_from: '',
date_to: '',
search: '',
kandang_id: '',
status: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
kandang_id: 'kandang_id',
status: 'status',
date_from: 'date_from',
date_to: 'date_to',
},
});
const {
data: checklistListRes,
isLoading: isLoadingChecklistList,
mutate: refreshChecklistList,
} = useSWR(
`${DailyChecklistApi.basePath}${getTableFilterQueryString()}`,
DailyChecklistApi.getAllFetcher,
{
keepPreviousData: true,
}
);
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search', {
page: '1',
limit: '100',
});
const checklistList = isResponseSuccess(checklistListRes)
? checklistListRes.data || []
: [];
// Modals
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const handleDetail = (item: DailyChecklist) => {
router.push(
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}`
);
};
const handleApprove = (item: DailyChecklist) => {
setSelectedItem(item);
setShowApproveModal(true);
};
const handleReject = (item: DailyChecklist) => {
setSelectedItem(item);
setRejectReason('');
setShowRejectModal(true);
};
const handleDelete = (item: DailyChecklist) => {
// ✅ VALIDATION: Only DRAFT can be deleted
if (item.status !== 'DRAFT') {
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
description: `Status saat ini: ${item.status}`,
});
return;
}
setSelectedItem(item);
setShowDeleteModal(true);
};
const confirmApprove = async () => {
if (!selectedItem) return;
try {
setActionLoading(true);
const approveRes = await DailyChecklistApi.approve(
String(selectedItem.id)
);
if (isResponseError(approveRes)) {
toast.error('Gagal approve checklist: ' + approveRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-approve');
setShowApproveModal(false);
setSelectedItem(null);
} catch (error) {
console.error('Error approving checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmReject = async () => {
if (!selectedItem) return;
if (!rejectReason.trim()) {
toast.error('Alasan reject harus diisi');
return;
}
try {
setActionLoading(true);
const rejectRes = await DailyChecklistApi.reject(
String(selectedItem.id),
rejectReason
);
if (isResponseError(rejectRes)) {
toast.error('Gagal reject checklist: ' + rejectRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-reject');
setShowRejectModal(false);
setSelectedItem(null);
setRejectReason('');
} catch (error) {
console.error('Error rejecting checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmDelete = async () => {
if (!selectedItem) return;
try {
setActionLoading(true);
const deleteRes = await DailyChecklistApi.delete(selectedItem.id);
if (isResponseError(deleteRes)) {
toast.error('Gagal hapus checklist: ' + deleteRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil dihapus');
setShowDeleteModal(false);
setSelectedItem(null);
} catch (error) {
console.error('Error deleting checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'DRAFT':
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
Draft
</Badge>
);
case 'SUBMITTED':
return (
<Badge
variant='outline'
className='border-orange-300 text-orange-700 bg-white'
>
Submitted
</Badge>
);
case 'APPROVED':
return (
<Badge
variant='outline'
className='border-green-300 text-green-700 bg-white'
>
Approved
</Badge>
);
case 'REJECTED':
return (
<Badge
variant='outline'
className='border-red-300 text-red-700 bg-white'
>
Rejected
</Badge>
);
default:
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
{status}
</Badge>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const checklistListColumns: ColumnDef<DailyChecklist>[] = [
{
accessorKey: 'date',
header: 'Tanggal',
enableSorting: false,
cell: ({ row }) => formatDate(row.original.date),
},
{
accessorKey: 'kandang',
header: 'Kandang',
enableSorting: false,
cell: ({ row }) => row.original.kandang.name,
},
{
accessorKey: 'category',
header: 'Kategori',
enableSorting: false,
cell: ({ row }) =>
CATEGORY_LABELS[row.original.category] || row.original.category,
},
{
accessorKey: 'status',
header: 'Status',
enableSorting: false,
cell: ({ row }) => getStatusBadge(row.original.status),
},
{
accessorKey: 'total_phase',
header: 'Total Phase',
enableSorting: false,
},
{
accessorKey: 'total_activity',
header: 'Total Aktivitas',
enableSorting: false,
},
{
accessorKey: 'progress',
header: 'Progress',
enableSorting: false,
cell: ({ row }) => (
<div className='flex items-center justify-center gap-2'>
<div className='w-24 bg-gray-200 rounded-full h-2'>
<div
className='bg-[#0069e0] h-2 rounded-full transition-all'
style={{ width: `${row.original.progress}%` }}
/>
</div>
<span className='text-sm text-gray-700 font-medium'>
{row.original.progress}%
</span>
</div>
),
},
{
accessorKey: 'updated_at',
header: 'Update At',
enableSorting: false,
cell: ({ row }) => formatDateTime(row.original.updated_at),
},
{
id: 'action',
header: 'Aksi',
accessorKey: 'action',
enableSorting: false,
cell: ({ row }) => (
<div className='flex items-center justify-center gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => handleDetail(row.original)}
className='border-gray-200 text-gray-700 hover:bg-gray-50'
>
<Eye className='w-4 h-4 mr-1' />
Detail
</Button>
{row.original.status === 'SUBMITTED' && (
<>
<Button
size='sm'
onClick={() => handleApprove(row.original)}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-1' />
Approve
</Button>
<Button
size='sm'
variant='destructive'
onClick={() => handleReject(row.original)}
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-1' />
Reject
</Button>
</>
)}
<Button
size='sm'
variant='destructive'
onClick={() => handleDelete(row.original)}
className='bg-red-600 hover:bg-red-700 text-white'
>
<Trash2 className='w-4 h-4 mr-1' />
Hapus
</Button>
</div>
),
},
];
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
List Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Daftar semua checklist harian
</p>
</div>
{/* Main Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-6'>
{/* Filters Section */}
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 pb-6 border-b border-gray-200'>
<div>
<Label>Periode Tanggal</Label>
<div className='mt-1.5'>
<DateRangePicker
dateFrom={tableFilterState.date_from}
dateTo={tableFilterState.date_to}
onDateChange={(from, to) => {
updateFilter('date_from', from);
updateFilter('date_to', to);
}}
/>
</div>
</div>
<div>
<Label htmlFor='kandang-filter'>Kandang</Label>
<div className='mt-1.5'>
<Select
value={tableFilterState.kandang_id}
onValueChange={(value) => {
updateFilter('kandang_id', value === 'ALL' ? '' : value);
}}
>
<SelectTrigger
id='kandang-filter'
className='border-gray-200'
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
key={kandang.value}
value={String(kandang.value)}
>
{kandang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor='status-filter'>Status</Label>
<div className='mt-1.5'>
<Select
value={tableFilterState.status}
onValueChange={(value) => {
updateFilter('status', value === 'ALL' ? '' : value);
}}
>
<SelectTrigger
id='status-filter'
className='border-gray-200'
>
<SelectValue placeholder='Semua Status' />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor='search-text'>Cari</Label>
<div className='relative mt-1.5'>
<DebouncedTextInput
name='search'
placeholder='Kandang / Kategori'
value={tableFilterState.search}
onChange={(e) => updateFilter('search', e.target.value)}
className={{
wrapper: 'w-full border-gray-200',
inputWrapper: 'px-3 py-2 h-fit rounded-md',
input: 'text-sm',
}}
startAdornment={
<Search className='text-gray-400 w-4 h-4' />
}
/>
</div>
</div>
</div>
{/* Table Section */}
<Table<DailyChecklist>
data={checklistList}
columns={checklistListColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(checklistListRes)
? checklistListRes?.meta?.page
: 0
}
totalItems={
isResponseSuccess(checklistListRes)
? checklistListRes?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingChecklistList}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(checklistListRes) &&
checklistListRes?.data?.length === 0,
}),
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-none',
headerRowClassName: 'bg-gray-50/50',
headerColumnClassName:
'text-left py-3.5 px-6 text-sm font-semibold text-gray-700',
paginationClassName: 'px-4',
}}
/>
</CardContent>
</Card>
</div>
{/* Approve Modal */}
<Dialog open={showApproveModal} onOpenChange={setShowApproveModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Approve Checklist</DialogTitle>
<DialogDescription>
Apakah Anda yakin ingin approve checklist ini?
</DialogDescription>
</DialogHeader>
{selectedItem && (
<div className='bg-gray-50 rounded-lg p-4 space-y-2'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(selectedItem.date)}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{CATEGORY_LABELS[selectedItem.category] ||
selectedItem.category}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Progress:</span>
<span className='font-medium text-gray-900'>
{selectedItem.progress}%
</span>
</div>
</div>
)}
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowApproveModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Modal */}
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Reject Checklist</DialogTitle>
<DialogDescription>
Berikan alasan reject untuk checklist ini
</DialogDescription>
</DialogHeader>
{selectedItem && (
<div className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(selectedItem.date)}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{CATEGORY_LABELS[selectedItem.category] ||
selectedItem.category}
</span>
</div>
</div>
)}
<div>
<Label htmlFor='reject-reason'>
Alasan Reject <span className='text-red-500'>*</span>
</Label>
<Textarea
id='reject-reason'
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder='Tuliskan alasan reject...'
className='mt-1.5 border-gray-200 min-h-[100px]'
disabled={actionLoading}
/>
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowRejectModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Modal */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle className='text-red-600'>
Hapus Checklist
</DialogTitle>
<DialogDescription>
Apakah Anda yakin ingin menghapus checklist ini? Data yang dihapus
tidak dapat dikembalikan.
</DialogDescription>
</DialogHeader>
{selectedItem && (
<>
<div className='bg-red-50 border border-red-200 rounded-lg p-4 space-y-2 mb-2'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(selectedItem.date)}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{CATEGORY_LABELS[selectedItem.category] ||
selectedItem.category}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Status:</span>
{getStatusBadge(selectedItem.status)}
</div>
</div>
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3'>
<p className='text-xs text-yellow-800'>
<strong>Peringatan:</strong> Semua data terkait (phases,
activities, assignments) akan ikut terhapus secara permanen.
</p>
</div>
</>
)}
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowDeleteModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmDelete}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Hapus'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,973 @@
'use client';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge';
import { Label } from '@/figma-make/components/base/label';
import { Textarea } from '@/figma-make/components/base/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/figma-make/components/base/dialog';
import { toast } from 'sonner';
import { useRouter, useSearchParams } from 'next/navigation';
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
import { isResponseError } from '@/lib/api-helper';
interface ChecklistDetailRow {
checklist_id: string;
date: string;
kandang_name: string;
category: string;
status: string;
reject_reason: string | null;
phase_id: string;
phase_name: string;
activity_id: string;
activity_name: string;
activity_description: string | null;
time_type: string;
employee_id: string;
employee_name: string;
checked: boolean;
note: string | null;
}
interface ChecklistHeader {
date: string;
kandang_name: string;
category: string;
status: string;
reject_reason: string | null;
progress_percent: number;
total_phases: number;
total_activities: number;
}
interface PhaseGroup {
phase: {
id: string;
name: string;
};
timeGroups: {
[timeType: string]: {
activities: {
id: string;
name: string;
description: string | null;
employees: {
id: string;
name: string;
checked: boolean;
note: string | null;
}[];
}[];
};
};
}
interface ChecklistData {
id: number;
date: string;
kandang_id: string;
category: string;
status: string;
reject_reason: string | null;
kandang: {
id: number;
name: string;
};
}
interface AssignmentQueryResult {
task_id: number;
employee_id: string;
checked: boolean;
note: string | null;
employees: {
id: number;
name: string;
} | null;
}
const CATEGORY_LABELS: { [key: string]: string } = {
pullet_open: 'Pullet Open',
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
};
const TIME_TYPE_ORDER = ['Umum', 'Pagi', 'Siang', 'Sore', 'Malam'];
const TIME_TYPE_LABELS: { [key: string]: string } = {
Umum: 'Umum',
Pagi: 'Pagi',
Siang: 'Siang',
Sore: 'Sore',
Malam: 'Malam',
};
export function DetailDailyChecklistContent() {
const router = useRouter();
const searchParams = useSearchParams();
const checklistId = searchParams.get('checklistId');
const [loading, setLoading] = useState(true);
const [header, setHeader] = useState<ChecklistHeader | null>(null);
const [detailRows, setDetailRows] = useState<ChecklistDetailRow[]>([]);
const [phaseGroups, setPhaseGroups] = useState<PhaseGroup[]>([]);
const [employees, setEmployees] = useState<{ id: string; name: string }[]>(
[]
);
// Modals
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
if (checklistId) {
fetchChecklistDetail();
}
}, [checklistId]);
const fetchChecklistDetail = async () => {
if (!checklistId) {
console.warn('checklistId missing');
setLoading(false);
return;
}
try {
setLoading(true);
const checklistDataRes =
await DailyChecklistApi.getOneDailyChecklist(checklistId);
if (isResponseError(checklistDataRes)) {
console.error('Error fetching checklist:', checklistDataRes.message);
toast.error('Data checklist tidak ditemukan');
router.push('/daily-checklist/list-daily-checklist');
return;
}
const rawDetailChecklist = checklistDataRes?.data;
const checklistData = {
id: rawDetailChecklist?.id,
date: rawDetailChecklist?.date,
kandang_id: rawDetailChecklist?.kandang.id,
category: rawDetailChecklist?.category,
status: rawDetailChecklist?.status,
reject_reason: rawDetailChecklist?.reject_reason,
kandang: rawDetailChecklist?.kandang,
};
const tasks = rawDetailChecklist?.tasks;
const castedChecklistData =
checklistData as unknown as ChecklistData | null;
if (!tasks || tasks.length === 0) {
toast.info('Checklist belum memiliki aktivitas');
setHeader({
date: castedChecklistData?.date || '-',
kandang_name: castedChecklistData?.kandang?.name || '-',
category: castedChecklistData?.category || '-',
status: castedChecklistData?.status || '-',
reject_reason: castedChecklistData?.reject_reason || '-',
progress_percent: 0,
total_phases: 0,
total_activities: 0,
});
setLoading(false);
return;
}
const assignments: {
task_id: number;
checked: boolean;
note: string | null;
employee: {
id: number;
name: string;
};
}[] = [];
tasks.forEach((task) => {
task.assignments.forEach((assignment) => {
assignments.push({
task_id: task.id,
checked: assignment.checked,
note: assignment.note,
employee: assignment.employee,
});
});
});
// ✅ Build detail rows from tasks and assignments
const detailRows: ChecklistDetailRow[] = [];
tasks.forEach((task) => {
const taskAssignments = assignments.filter(
(a) => a.task_id === task.id
);
taskAssignments.forEach((assignment) => {
detailRows.push({
checklist_id: checklistId,
date: castedChecklistData?.date || '-',
kandang_name: castedChecklistData?.kandang?.name || '-',
category: castedChecklistData?.category || '-',
status: castedChecklistData?.status || '-',
reject_reason: castedChecklistData?.reject_reason || '-',
phase_id: String(task.phase_id),
phase_name: task.phase?.name || '-',
activity_id: String(task.phase_activity_id),
activity_name: task.phase_activity?.name || '-',
activity_description: task.phase_activity?.description || null,
time_type: task.time_type,
employee_id: String(assignment.employee.id),
employee_name: assignment.employee?.name || '-',
checked: assignment.checked,
note: assignment.note,
});
});
});
if (detailRows.length === 0) {
toast.info('Checklist belum memiliki assignment ABK');
setHeader({
date: castedChecklistData?.date || '-',
kandang_name: castedChecklistData?.kandang?.name || '-',
category: castedChecklistData?.category || '-',
status: castedChecklistData?.status || '-',
reject_reason: castedChecklistData?.reject_reason || '-',
progress_percent: 0,
total_phases: new Set(tasks.map((t) => t.phase_id)).size,
total_activities: tasks.length,
});
setLoading(false);
return;
}
setDetailRows(detailRows);
// Extract unique employees
const uniqueEmployees = Array.from(
new Map(
detailRows.map((row) => [
row.employee_id,
{ id: row.employee_id, name: row.employee_name },
])
).values()
);
setEmployees(uniqueEmployees);
// Group data by Phase → Time Type → Activity
groupDetailData(detailRows);
// Calculate progress
const totalCheckboxes = detailRows.length;
const checkedCount = detailRows.filter((row) => row.checked).length;
const progressPercent =
totalCheckboxes > 0
? Math.round((checkedCount / totalCheckboxes) * 100)
: 0;
const uniquePhases = new Set(detailRows.map((row) => row.phase_id));
const uniqueActivities = new Set(
detailRows.map((row) => row.activity_id)
);
setHeader({
date: castedChecklistData?.date || '-',
kandang_name: castedChecklistData?.kandang?.name || '-',
category: castedChecklistData?.category || '-',
status: castedChecklistData?.status || '-',
reject_reason: castedChecklistData?.reject_reason || '-',
progress_percent: progressPercent,
total_phases: uniquePhases.size,
total_activities: uniqueActivities.size,
});
} catch (error) {
console.error('Error fetching checklist detail:', error);
toast.error('Terjadi kesalahan');
router.push('/daily-checklist/list-daily-checklist');
} finally {
setLoading(false);
}
};
const groupDetailData = (rows: ChecklistDetailRow[]) => {
// Group by phase_id
const phaseMap = new Map<
string,
{
phase: { id: string; name: string };
activities: Map<
string,
{
id: string;
name: string;
description: string | null;
time_type: string;
employees: Map<
string,
{
id: string;
name: string;
checked: boolean;
note: string | null;
}
>;
}
>;
}
>();
rows.forEach((row) => {
if (!phaseMap.has(row.phase_id)) {
phaseMap.set(row.phase_id, {
phase: { id: row.phase_id, name: row.phase_name },
activities: new Map(),
});
}
const phaseData = phaseMap.get(row.phase_id)!;
if (!phaseData.activities.has(row.activity_id)) {
phaseData.activities.set(row.activity_id, {
id: row.activity_id,
name: row.activity_name,
description: row.activity_description,
time_type: row.time_type,
employees: new Map(),
});
}
const activityData = phaseData.activities.get(row.activity_id)!;
activityData.employees.set(row.employee_id, {
id: row.employee_id,
name: row.employee_name,
checked: row.checked,
note: row.note,
});
});
// Convert to array and group by time_type
const grouped: PhaseGroup[] = [];
phaseMap.forEach((phaseData, phaseId) => {
const timeGroups: {
[timeType: string]: {
activities: {
id: string;
name: string;
description: string | null;
employees: {
id: string;
name: string;
checked: boolean;
note: string | null;
}[];
}[];
};
} = {};
phaseData.activities.forEach((activityData) => {
const timeType = activityData.time_type || 'umum';
if (!timeGroups[timeType]) {
timeGroups[timeType] = { activities: [] };
}
timeGroups[timeType].activities.push({
id: activityData.id,
name: activityData.name,
description: activityData.description,
employees: Array.from(activityData.employees.values()),
});
});
grouped.push({
phase: phaseData.phase,
timeGroups,
});
});
setPhaseGroups(grouped);
};
const handleApprove = () => {
setShowApproveModal(true);
};
const handleReject = () => {
setRejectReason('');
setShowRejectModal(true);
};
const confirmApprove = async () => {
if (!checklistId) return;
try {
setActionLoading(true);
const approveRes = await DailyChecklistApi.approve(String(checklistId));
if (isResponseError(approveRes)) {
toast.error('Gagal approve checklist: ' + approveRes.message);
return;
}
toast.success('Checklist berhasil di-approve');
setShowApproveModal(false);
await fetchChecklistDetail();
} catch (error) {
console.error('Error approving checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmReject = async () => {
if (!checklistId) return;
if (!rejectReason.trim()) {
toast.error('Alasan reject harus diisi');
return;
}
try {
setActionLoading(true);
const rejectRes = await DailyChecklistApi.reject(
String(checklistId),
rejectReason
);
if (isResponseError(rejectRes)) {
toast.error('Gagal reject checklist: ' + rejectRes.message);
return;
}
toast.success('Checklist berhasil di-reject');
setShowRejectModal(false);
setRejectReason('');
await fetchChecklistDetail();
} catch (error) {
console.error('Error rejecting checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'DRAFT':
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
Draft
</Badge>
);
case 'SUBMITTED':
return (
<Badge
variant='outline'
className='border-orange-300 text-orange-700 bg-white'
>
Submitted
</Badge>
);
case 'APPROVED':
return (
<Badge
variant='outline'
className='border-green-300 text-green-700 bg-white'
>
Approved
</Badge>
);
case 'REJECTED':
return (
<Badge
variant='outline'
className='border-red-300 text-red-700 bg-white'
>
Rejected
</Badge>
);
default:
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
{status}
</Badge>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
if (loading) {
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Detail Daily Checklist
</h1>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Memuat data...
</CardContent>
</Card>
</div>
</div>
);
}
if (!header) {
return null;
}
const isReadOnly =
header.status === 'APPROVED' || header.status === 'REJECTED';
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title with Back Button */}
<div className='mb-6 flex items-center gap-4'>
<Button
variant='outline'
size='sm'
onClick={() => router.push('/daily-checklist/list-daily-checklist')}
className='border-gray-200'
>
<ArrowLeft className='w-4 h-4 mr-1' />
Kembali
</Button>
<div className='flex-1'>
<h1 className='text-2xl font-semibold text-gray-900'>
Detail Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Lihat detail checklist harian
</p>
</div>
{header.status === 'SUBMITTED' && (
<div className='flex gap-2'>
<Button
onClick={handleApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-2' />
Approve
</Button>
<Button
onClick={handleReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-2' />
Reject
</Button>
</div>
)}
</div>
{/* Header Info Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
<CardContent className='p-6'>
<div className='grid grid-cols-2 md:grid-cols-4 gap-6'>
<div>
<Label className='text-xs text-gray-500'>Tanggal</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{formatDate(header.date)}
</p>
</div>
<div>
<Label className='text-xs text-gray-500'>Kandang</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.kandang_name}
</p>
</div>
<div>
<Label className='text-xs text-gray-500'>Kategori</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{CATEGORY_LABELS[header.category] || header.category}
</p>
</div>
<div>
<Label className='text-xs text-gray-500'>Status</Label>
<div className='mt-1'>{getStatusBadge(header.status)}</div>
</div>
<div>
<Label className='text-xs text-gray-500'>Total Phase</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.total_phases} fase
</p>
</div>
<div>
<Label className='text-xs text-gray-500'>Total Aktivitas</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.total_activities} aktivitas
</p>
</div>
<div className='col-span-2'>
<Label className='text-xs text-gray-500'>Progress</Label>
<div className='flex items-center gap-3 mt-2'>
<div className='flex-1 bg-gray-200 rounded-full h-2.5'>
<div
className='bg-[#0069e0] h-2.5 rounded-full transition-all'
style={{
width: `${header.progress_percent}%`,
}}
/>
</div>
<span className='text-sm font-medium text-gray-900'>
{header.progress_percent}%
</span>
</div>
</div>
</div>
{/* Reject Reason if rejected */}
{header.status === 'REJECTED' && header.reject_reason && (
<div className='mt-6 pt-6 border-t border-gray-200'>
<div className='flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg'>
<AlertCircle className='w-5 h-5 text-red-600 mt-0.5 flex-shrink-0' />
<div>
<Label className='text-sm font-medium text-red-900'>
Alasan Reject
</Label>
<p className='text-sm text-red-700 mt-1'>
{header.reject_reason}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Activity Checklist Table */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-6'>
<h3 className='font-semibold text-gray-900 mb-4'>
Checklist Aktivitas
</h3>
{phaseGroups.length > 0 ? (
<div className='overflow-x-auto'>
<table className='w-full border border-gray-200 rounded-lg'>
<thead>
<tr className='bg-gray-50 border-b border-gray-200'>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[200px]'>
Aktivitas
</th>
{employees.map((emp) => (
<th
key={emp.id}
className='text-center py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[100px]'
>
{emp.name}
</th>
))}
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700 min-w-[200px]'>
Catatan
</th>
</tr>
</thead>
<tbody>
{phaseGroups.flatMap((phaseGroup) => {
const timeTypes = Object.keys(phaseGroup.timeGroups).sort(
(a, b) =>
TIME_TYPE_ORDER.indexOf(a) -
TIME_TYPE_ORDER.indexOf(b)
);
const totalActivities = timeTypes.reduce(
(sum, timeType) =>
sum +
phaseGroup.timeGroups[timeType].activities.length,
0
);
const rows = [];
// PHASE Header - BLUE
rows.push(
<tr
key={`phase-${phaseGroup.phase.id}`}
className='bg-blue-50 border-b border-blue-200'
>
<td
colSpan={employees.length + 2}
className='py-2.5 px-4'
>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold text-blue-900'>
{phaseGroup.phase.name}
</span>
<Badge
variant='secondary'
className='text-xs bg-blue-100 text-blue-700 border-blue-200 rounded-lg'
>
{totalActivities} aktivitas
</Badge>
</div>
</td>
</tr>
);
// TIME_TYPE sub-headers and activities
timeTypes.forEach((timeType) => {
const timeGroup = phaseGroup.timeGroups[timeType];
const hasMultipleTimeTypes = timeTypes.length > 1;
// TIME Header (optional) - GRAY SOFT
if (hasMultipleTimeTypes) {
rows.push(
<tr
key={`time-${phaseGroup.phase.id}-${timeType}`}
className='bg-gray-50 border-b border-gray-200'
>
<td
colSpan={employees.length + 2}
className='py-2 px-4 pl-8'
>
<span className='text-xs font-medium text-gray-600'>
{TIME_TYPE_LABELS[timeType]} (
{timeGroup.activities.length} aktivitas)
</span>
</td>
</tr>
);
}
// ACTIVITY rows
timeGroup.activities.forEach((activity, index) => {
const indentClass = hasMultipleTimeTypes
? 'pl-12'
: 'pl-8';
rows.push(
<tr
key={`activity-${activity.id}-${index}`}
className={
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
}
>
<td
className={`py-3 px-4 ${indentClass} border-r border-gray-200`}
>
<p className='text-sm text-gray-900'>
{activity.name}
</p>
{activity.description && (
<p className='text-xs text-gray-500 mt-0.5'>
{activity.description}
</p>
)}
</td>
{employees.map((emp) => {
const empData = activity.employees.find(
(e) => e.id === emp.id
);
return (
<td
key={emp.id}
className='text-center py-3 px-4 border-r border-gray-200'
>
<input
type='checkbox'
checked={empData?.checked || false}
disabled
className='checkbox-clean cursor-not-allowed'
/>
</td>
);
})}
<td className='py-3 px-4'>
{activity.employees.length > 0 &&
activity.employees[0].note ? (
<p className='text-sm text-gray-600'>
{activity.employees[0].note}
</p>
) : (
<p className='text-xs text-gray-400 italic'>
Tidak ada catatan
</p>
)}
</td>
</tr>
);
});
});
return rows;
})}
</tbody>
</table>
</div>
) : (
<div className='text-center py-12 text-gray-500'>
Tidak ada data aktivitas
</div>
)}
</CardContent>
</Card>
</div>
{/* Approve Modal */}
<Dialog open={showApproveModal} onOpenChange={setShowApproveModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Approve Checklist</DialogTitle>
<DialogDescription>
Apakah Anda yakin ingin approve checklist ini?
</DialogDescription>
</DialogHeader>
<div className='bg-gray-50 rounded-lg p-4 space-y-2'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(header.date)}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{header.kandang_name}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{CATEGORY_LABELS[header.category] || header.category}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Progress:</span>
<span className='font-medium text-gray-900'>
{header.progress_percent}%
</span>
</div>
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowApproveModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Modal */}
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Reject Checklist</DialogTitle>
<DialogDescription>
Berikan alasan reject untuk checklist ini
</DialogDescription>
</DialogHeader>
<div className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(header.date)}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{header.kandang_name}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{CATEGORY_LABELS[header.category] || header.category}
</span>
</div>
</div>
<div>
<Label htmlFor='reject-reason'>
Alasan Reject <span className='text-red-500'>*</span>
</Label>
<Textarea
id='reject-reason'
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder='Tuliskan alasan reject...'
className='mt-1.5 border-gray-200 min-h-[100px]'
disabled={actionLoading}
/>
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowRejectModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,913 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Label } from '@/figma-make/components/base/label';
import { Input } from '@/figma-make/components/base/input';
import { Textarea } from '@/figma-make/components/base/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/figma-make/components/base/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/figma-make/components/base/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/figma-make/components/base/dropdown-menu';
import { toast } from 'sonner';
import useSWR from 'swr';
import { PhaseApi } from '@/services/api/daily-checklist/phase';
import { BaseApiResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { PhaseActivityApi } from '@/services/api/daily-checklist/phase-activity';
import { PhaseActivity } from '@/types/api/daily-checklist/phase-activity';
import { Phase } from '@/types/api/daily-checklist/phase';
// Static categories - tidak bisa CRUD
const CATEGORIES = [
{ value: 'pullet_open', label: 'Pullet Open' },
{ value: 'pullet_close', label: 'Pullet Close' },
{ value: 'produksi_open', label: 'Produksi Open' },
{ value: 'produksi_close', label: 'Produksi Close' },
];
const TIME_TYPES = [
{ value: 'Umum', label: 'Umum' },
{ value: 'Pagi', label: 'Pagi' },
{ value: 'Siang', label: 'Siang' },
{ value: 'Sore', label: 'Sore' },
{ value: 'Malam', label: 'Malam' },
];
export function MasterAktivitasContent() {
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null);
const {
data: phases,
isLoading: isLoadingPhases,
mutate: refreshPhases,
} = useSWR<
BaseApiResponse<Phase[] | undefined>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
selectedCategory
? `${PhaseApi.basePath}?page=1&limit=100&category=${selectedCategory}`
: '',
httpClientFetcher,
{
keepPreviousData: true,
}
);
const {
data: phaseActivities,
isLoading: isLoadingPhaseActivities,
mutate: refreshPhaseActivities,
} = useSWR<
BaseApiResponse<PhaseActivity[] | undefined>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
selectedPhase?.id
? `${PhaseActivityApi.basePath}?page=1&limit=100&phase_ids=${selectedPhase.id}`
: '',
httpClientFetcher,
{
keepPreviousData: true,
}
);
const [showPhaseModal, setShowPhaseModal] = useState(false);
const [showActivityModal, setShowActivityModal] = useState(false);
const [showPhaseDeleteConfirm, setShowPhaseDeleteConfirm] = useState(false);
const [showActivityDeleteConfirm, setShowActivityDeleteConfirm] =
useState(false);
const [phaseToDelete, setPhaseToDelete] = useState<string | null>(null);
const [activityToDelete, setActivityToDelete] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [phaseSearchQuery, setPhaseSearchQuery] = useState('');
const [phaseModalMode, setPhaseModalMode] = useState<'create' | 'edit'>(
'create'
);
const filteredPhases =
(isResponseSuccess(phases) &&
phases?.data?.filter((phase) => {
return phase.name
.toLowerCase()
.includes(phaseSearchQuery.toLowerCase());
})) ||
[];
const [activityModalMode, setActivityModalMode] = useState<'create' | 'edit'>(
'create'
);
const [phaseForm, setPhaseForm] = useState({
id: '',
name: '',
});
const [activityForm, setActivityForm] = useState({
id: '',
name: '',
description: '',
time_type: 'umum',
});
useEffect(() => {
setInitialLoading(false);
}, []);
// Phase handlers
const handleAddPhase = () => {
if (!selectedCategory) {
toast.error('Pilih kategori terlebih dahulu');
return;
}
setPhaseModalMode('create');
setPhaseForm({ id: '', name: '' });
setShowPhaseModal(true);
};
const handleEditPhase = (phase: Phase) => {
setPhaseModalMode('edit');
setPhaseForm({
id: String(phase.id),
name: phase.name,
});
setShowPhaseModal(true);
};
const handleSavePhase = async () => {
if (!phaseForm.name.trim()) {
toast.error('Nama phase harus diisi');
return;
}
if (phaseForm.name.trim().length < 3) {
toast.error('Nama phase minimal 3 karakter!');
return;
}
if (!selectedCategory) {
toast.error('Pilih kategori terlebih dahulu');
return;
}
setLoading(true);
try {
if (phaseModalMode === 'create') {
const createPhaseResponse = await PhaseApi.create({
category: selectedCategory,
name: phaseForm.name.trim(),
});
if (isResponseError(createPhaseResponse)) {
console.error('Error creating phase:', createPhaseResponse.message);
toast.error('Gagal menambahkan phase');
return;
}
refreshPhases();
toast.success('Phase berhasil ditambahkan');
} else {
const updatePhaseResponse = await PhaseApi.update(
Number(phaseForm.id),
{
name: phaseForm.name.trim(),
}
);
if (isResponseError(updatePhaseResponse)) {
console.error('Error creating phase:', updatePhaseResponse.message);
toast.error('Gagal menambahkan phase');
return;
}
refreshPhases();
toast.success('Phase berhasil diubah');
}
setShowPhaseModal(false);
setPhaseForm({ id: '', name: '' });
} catch (error) {
console.error('Error saving phase:', error);
toast.error('Terjadi kesalahan saat menyimpan phase');
} finally {
setLoading(false);
}
};
const handleDeletePhaseClick = (phaseId: string) => {
setPhaseToDelete(phaseId);
setShowPhaseDeleteConfirm(true);
};
const handleConfirmDeletePhase = async () => {
if (!phaseToDelete || !selectedCategory) return;
setLoading(true);
try {
const deletePhaseResponse = await PhaseApi.delete(Number(phaseToDelete));
if (isResponseError(deletePhaseResponse)) {
console.error('Error deleting phase:', deletePhaseResponse.message);
toast.error('Gagal menghapus phase');
setLoading(false);
return;
}
refreshPhases();
toast.success('Phase dan semua aktivitasnya berhasil dihapus');
setShowPhaseDeleteConfirm(false);
setPhaseToDelete(null);
// Clear selection if deleted phase was selected
if (selectedPhase?.id === Number(phaseToDelete)) {
setSelectedPhase(null);
}
} catch (error) {
console.error('Error deleting phase:', error);
toast.error('Terjadi kesalahan saat menghapus phase');
} finally {
setLoading(false);
}
};
// Activity handlers
const handleAddActivity = () => {
if (!selectedPhase) {
toast.error('Pilih phase terlebih dahulu');
return;
}
setActivityModalMode('create');
setActivityForm({ id: '', name: '', description: '', time_type: 'umum' });
setShowActivityModal(true);
};
const handleEditActivity = (activity: PhaseActivity) => {
setActivityModalMode('edit');
setActivityForm({
id: String(activity.id),
name: activity.name,
description: activity.description || '',
time_type: activity.time_type,
});
setShowActivityModal(true);
};
const handleSaveActivity = async () => {
if (!activityForm.name.trim()) {
toast.error('Nama aktivitas harus diisi');
return;
}
if (activityForm.name.trim().length < 3) {
toast.error('Nama aktivitas minimal 3 karakter!');
return;
}
if (!selectedPhase) {
toast.error('Pilih phase terlebih dahulu');
return;
}
setLoading(true);
try {
if (activityModalMode === 'create') {
const createActivityResponse = await PhaseActivityApi.create({
phase_id: Number(selectedPhase.id),
name: activityForm.name.trim(),
description: activityForm.description.trim() || '',
time_type: activityForm.time_type,
});
if (isResponseError(createActivityResponse)) {
console.error(
'Error creating activity:',
createActivityResponse.message
);
toast.error('Gagal menambahkan aktivitas');
return;
}
refreshPhaseActivities();
toast.success('Aktivitas berhasil ditambahkan');
} else {
const updateActivityResponse = await PhaseActivityApi.update(
Number(activityForm.id),
{
name: activityForm.name.trim(),
description: activityForm.description.trim() || '',
time_type: activityForm.time_type,
}
);
if (isResponseError(updateActivityResponse)) {
console.error(
'Error updating activity:',
updateActivityResponse.message
);
toast.error('Gagal mengubah aktivitas');
return;
}
refreshPhaseActivities();
toast.success('Aktivitas berhasil diubah');
}
setShowActivityModal(false);
setActivityForm({ id: '', name: '', description: '', time_type: 'umum' });
} catch (error) {
console.error('Error saving activity:', error);
toast.error('Terjadi kesalahan saat menyimpan aktivitas');
} finally {
setLoading(false);
}
};
const handleDeleteActivityClick = (activityId: string) => {
setActivityToDelete(activityId);
setShowActivityDeleteConfirm(true);
};
const handleConfirmDeleteActivity = async () => {
if (!activityToDelete || !selectedPhase || !selectedCategory) return;
setLoading(true);
try {
const deleteActivityResponse = await PhaseActivityApi.delete(
Number(activityToDelete)
);
if (isResponseError(deleteActivityResponse)) {
console.error(
'Error deleting activity:',
deleteActivityResponse.message
);
toast.error('Gagal menghapus aktivitas');
return;
}
refreshPhaseActivities();
toast.success('Aktivitas berhasil dihapus');
setShowActivityDeleteConfirm(false);
setActivityToDelete(null);
} catch (error) {
console.error('Error deleting activity:', error);
toast.error('Terjadi kesalahan saat menghapus aktivitas');
} finally {
setLoading(false);
}
};
const getTimeTypeLabel = (timeType: string) => {
return TIME_TYPES.find((t) => t.value === timeType)?.label || timeType;
};
const getTimeTypeBadgeClass = (timeType: string) => {
switch (timeType.toLowerCase()) {
case 'umum':
return 'bg-gray-50 text-gray-700 border-gray-300';
case 'pagi':
return 'bg-orange-50 text-orange-700 border-orange-300';
case 'siang':
return 'bg-amber-50 text-amber-700 border-amber-300';
case 'sore':
return 'bg-purple-50 text-purple-700 border-purple-300';
case 'malam':
return 'bg-indigo-50 text-indigo-700 border-indigo-300';
default:
return 'bg-gray-50 text-gray-700 border-gray-300';
}
};
if (initialLoading) {
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Aktivitas
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data <span className='text-[#0069e0]'>Aktivitas</span>
</p>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Memuat data...
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Aktivitas
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data <span className='text-[#0069e0]'>Aktivitas</span>
</p>
</div>
{/* Category Selector Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white mb-6'>
<CardContent className='p-6'>
<div className='max-w-md'>
<Label htmlFor='category'>
Kategori <span className='text-red-500'>*</span>
</Label>
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger id='category' className='mt-1.5 border-gray-200'>
<SelectValue placeholder='Pilih kategori aktivitas' />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-xs text-gray-500 mt-2'>
Pilih kategori untuk melihat dan mengelola phase dan aktivitas
</p>
</div>
</CardContent>
</Card>
{selectedCategory ? (
<div className='grid grid-cols-1 lg:grid-cols-5 gap-6'>
{/* LEFT PANEL: Phase List */}
<div className='lg:col-span-2'>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-0'>
{/* Phase Toolbar */}
<div className='flex items-center justify-between gap-4 p-6 border-b border-gray-200/60'>
<div className='relative flex-1'>
<Search className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4' />
<Input
type='text'
placeholder='Cari phase...'
value={phaseSearchQuery}
onChange={(e) => setPhaseSearchQuery(e.target.value)}
className='pl-10 border-gray-200'
/>
</div>
<Button
onClick={handleAddPhase}
size='sm'
className='bg-[#0069e0] hover:bg-[#0052b3] text-white whitespace-nowrap'
>
<Plus className='w-4 h-4 mr-1' />
Phase
</Button>
</div>
{/* Phase List */}
<div className='divide-y divide-gray-200/60'>
{!isResponseSuccess(phases) ||
(isResponseSuccess(phases) && phases.data?.length === 0) ? (
<div className='p-8 text-center text-gray-500'>
{phaseSearchQuery
? 'Tidak ada phase yang ditemukan'
: 'Belum ada data phase'}
</div>
) : (
filteredPhases.map((phase) => (
<div
key={phase.id}
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
selectedPhase?.id === phase.id
? 'bg-blue-50 border-l-4 border-[#0069e0]'
: 'hover:bg-gray-50'
}`}
onClick={() => setSelectedPhase(phase)}
>
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-gray-900 truncate'>
{phase.name}
</p>
<p className='text-xs text-gray-500 mt-1'>
{phase.activity_count || 0} aktivitas
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0 hover:bg-gray-100'
>
<MoreVertical className='h-4 w-4 text-gray-600' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() => handleEditPhase(phase)}
>
<Pencil className='mr-2 h-4 w-4' />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleDeletePhaseClick(String(phase.id))
}
className='text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
{/* RIGHT PANEL: Activity Detail */}
<div className='lg:col-span-3'>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-0'>
{selectedPhase ? (
<>
{/* Activity Header */}
<div className='flex items-center justify-between p-6 border-b border-gray-200/60'>
<div>
<h2 className='text-lg font-semibold text-gray-900'>
Aktivitas di Phase: {selectedPhase.name}
</h2>
{isResponseSuccess(phaseActivities) && (
<p className='text-sm text-gray-600 mt-0.5'>
{phaseActivities.data?.length} aktivitas
</p>
)}
</div>
<Button
onClick={handleAddActivity}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
<Plus className='w-4 h-4 mr-2' />
Tambah Aktivitas
</Button>
</div>
{/* Activity Table */}
<div className='overflow-x-auto'>
<table className='w-full'>
<thead>
<tr className='border-b border-gray-200/60 bg-gray-50/50'>
<th className='text-left py-3.5 px-6 text-sm font-semibold text-gray-700'>
Nama Aktivitas
</th>
<th className='text-center py-3.5 px-6 text-sm font-semibold text-gray-700 w-[80px]'>
Aksi
</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-200/60'>
{!isResponseSuccess(phaseActivities) ||
(isResponseSuccess(phaseActivities) &&
phaseActivities.data?.length === 0) ? (
<tr>
<td
colSpan={2}
className='text-center py-12 text-gray-500'
>
Belum ada aktivitas di phase ini
</td>
</tr>
) : (
phaseActivities.data?.map((activity) => (
<tr
key={activity.id}
className='hover:bg-blue-50/30 transition-colors'
>
<td className='py-3.5 px-6'>
<div className='flex items-center gap-2'>
<div className='flex-1'>
<p className='text-sm text-gray-900'>
{activity.name}
</p>
{activity.description && (
<p className='text-xs text-gray-500 mt-1'>
{activity.description}
</p>
)}
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${getTimeTypeBadgeClass(activity.time_type)}`}
>
{getTimeTypeLabel(activity.time_type)}
</span>
</div>
</td>
<td className='py-3.5 px-6 text-center'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0 hover:bg-gray-100'
>
<MoreVertical className='h-4 w-4 text-gray-600' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() =>
handleEditActivity(activity)
}
>
<Pencil className='mr-2 h-4 w-4' />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleDeleteActivityClick(
String(activity.id)
)
}
className='text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
) : (
<div className='p-12 text-center text-gray-500'>
Pilih phase untuk melihat aktivitas
</div>
)}
</CardContent>
</Card>
</div>
</div>
) : (
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Pilih kategori terlebih dahulu untuk mengelola phase dan aktivitas
</CardContent>
</Card>
)}
</div>
{/* Phase Add/Edit Modal */}
<Dialog open={showPhaseModal} onOpenChange={setShowPhaseModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>
{phaseModalMode === 'create' ? 'Tambah Phase' : 'Edit Phase'}
</DialogTitle>
<DialogDescription>
{phaseModalMode === 'create'
? 'Masukkan detail phase baru'
: 'Ubah detail phase'}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div>
<Label htmlFor='nama-phase'>
Nama Phase <span className='text-red-500'>*</span>
</Label>
<Input
id='nama-phase'
value={phaseForm.name}
onChange={(e) =>
setPhaseForm({ ...phaseForm, name: e.target.value })
}
placeholder='Masukkan nama phase'
className='mt-1.5'
disabled={loading}
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowPhaseModal(false)}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSavePhase}
disabled={loading}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
{loading ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Activity Add/Edit Modal */}
<Dialog open={showActivityModal} onOpenChange={setShowActivityModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>
{activityModalMode === 'create'
? 'Tambah Aktivitas'
: 'Edit Aktivitas'}
</DialogTitle>
<DialogDescription>
{activityModalMode === 'create'
? 'Masukkan detail aktivitas baru'
: 'Ubah detail aktivitas'}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div>
<Label htmlFor='nama-aktivitas'>
Nama Aktivitas <span className='text-red-500'>*</span>
</Label>
<Input
id='nama-aktivitas'
value={activityForm.name}
onChange={(e) =>
setActivityForm({ ...activityForm, name: e.target.value })
}
placeholder='Masukkan nama aktivitas'
className='mt-1.5'
disabled={loading}
/>
</div>
<div>
<Label htmlFor='keterangan'>Keterangan (Opsional)</Label>
<Textarea
id='keterangan'
value={activityForm.description}
onChange={(e) =>
setActivityForm({
...activityForm,
description: e.target.value,
})
}
placeholder='Masukkan keterangan aktivitas'
className='mt-1.5'
rows={3}
disabled={loading}
/>
</div>
<div>
<Label htmlFor='tipe-aktivitas'>
Tipe Aktivitas <span className='text-red-500'>*</span>
</Label>
<Select
value={activityForm.time_type}
onValueChange={(value) =>
setActivityForm({ ...activityForm, time_type: value })
}
disabled={loading}
>
<SelectTrigger
id='tipe-aktivitas'
className='mt-1.5 border-gray-200'
>
<SelectValue placeholder='Pilih tipe aktivitas' />
</SelectTrigger>
<SelectContent>
{TIME_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowActivityModal(false)}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSaveActivity}
disabled={loading}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
{loading ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Phase Delete Confirmation */}
<AlertDialog
open={showPhaseDeleteConfirm}
onOpenChange={setShowPhaseDeleteConfirm}
>
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Phase?</AlertDialogTitle>
<AlertDialogDescription>
Menghapus phase akan menghapus semua aktivitas di dalamnya secara
permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDeletePhase}
disabled={loading}
className='bg-red-600 hover:bg-red-700 text-white'
>
{loading ? 'Menghapus...' : 'Hapus'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Activity Delete Confirmation */}
<AlertDialog
open={showActivityDeleteConfirm}
onOpenChange={setShowActivityDeleteConfirm}
>
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Aktivitas?</AlertDialogTitle>
<AlertDialogDescription>
Hapus aktivitas ini secara permanen?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDeleteActivity}
disabled={loading}
className='bg-red-600 hover:bg-red-700 text-white'
>
{loading ? 'Menghapus...' : 'Hapus'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,581 @@
'use client';
import { useState } from 'react';
import {
Plus,
Download,
ChevronDown,
MoreVertical,
Pencil,
Trash2,
Search,
} from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Label } from '@/figma-make/components/base/label';
import { Input } from '@/figma-make/components/base/input';
import { Badge } from '@/figma-make/components/base/badge';
import { MultiSelect } from '@/figma-make/components/base/multi-select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/figma-make/components/base/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/figma-make/components/base/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/figma-make/components/base/dropdown-menu';
import { toast } from 'sonner';
import useSWR from 'swr';
import { EmployeeApi } from '@/services/api/daily-checklist/employee';
import Table from '@/components/Table';
import { Employee } from '@/types/api/daily-checklist/employee';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
export function MasterEmployeeContent() {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
kandang_id: '',
status: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
kandang_id: 'kandang_id',
status: 'is_active',
},
});
const {
data: employees,
isLoading: isLoadingEmployees,
mutate: refreshEmployees,
} = useSWR(
`${EmployeeApi.basePath}${getTableFilterQueryString()}`,
EmployeeApi.getAllFetcher,
{
keepPreviousData: true,
}
);
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search', {
page: '1',
limit: '100',
});
const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [employeeToDelete, setEmployeeToDelete] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
const [employeeForm, setEmployeeForm] = useState({
id: 0,
name: '',
kandang_ids: [] as number[],
status: 'Active' as 'Active' | 'Non Active',
});
const employeeColumns: ColumnDef<Employee>[] = [
{
id: 'name',
header: 'Nama ABK',
accessorKey: 'name',
enableSorting: false,
},
{
id: 'kandang',
header: 'Kandang',
accessorKey: 'kandangs',
enableSorting: false,
cell: ({ row }) =>
row.original.kandangs.map((kandang) => kandang.name).join(', '),
},
{
id: 'status',
header: 'Status',
accessorKey: 'is_active',
enableSorting: false,
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'success' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Non Active'}
</Badge>
),
},
{
id: 'action',
header: 'Aksi',
accessorKey: 'action',
enableSorting: false,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0 hover:bg-gray-100'
>
<MoreVertical className='h-4 w-4 text-gray-600' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
<Pencil className='mr-2 h-4 w-4' />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(row.original.id)}
className='text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
const handleAdd = () => {
setModalMode('create');
setEmployeeForm({ id: 0, name: '', kandang_ids: [], status: 'Active' });
setShowModal(true);
};
const handleEdit = (employee: Employee) => {
setModalMode('edit');
setEmployeeForm({
id: employee.id,
name: employee.name,
kandang_ids: employee.kandangs ? employee.kandangs.map((k) => k.id) : [],
status: employee.is_active ? 'Active' : 'Non Active',
});
setShowModal(true);
};
const handleSave = async () => {
if (!employeeForm.name.trim() || employeeForm.kandang_ids.length === 0) {
toast.error('Nama ABK dan minimal satu Kandang harus diisi');
return;
}
setLoading(true);
try {
if (modalMode === 'create') {
const createEmployeeResponse = await EmployeeApi.create({
is_active: employeeForm.status === 'Active',
kandang_ids: employeeForm.kandang_ids,
name: employeeForm.name.trim(),
});
if (isResponseError(createEmployeeResponse)) {
console.error(
'Error creating employee:',
createEmployeeResponse.message
);
toast.error('Gagal menambahkan ABK');
return;
}
refreshEmployees();
toast.success('ABK berhasil ditambahkan');
} else {
const updateEmployeeResponse = await EmployeeApi.update(
employeeForm.id,
{
is_active: employeeForm.status === 'Active',
kandang_ids: employeeForm.kandang_ids,
name: employeeForm.name.trim(),
}
);
if (isResponseError(updateEmployeeResponse)) {
console.error(
'Error updating employee:',
updateEmployeeResponse.message
);
toast.error('Gagal menambahkan ABK');
return;
}
refreshEmployees();
toast.success('ABK berhasil diubah');
}
setShowModal(false);
setEmployeeForm({ id: 0, name: '', kandang_ids: [], status: 'Active' });
} catch (error) {
console.error('Error saving employee:', error);
toast.error('Terjadi kesalahan saat menyimpan ABK');
} finally {
setLoading(false);
}
};
const handleDeleteClick = (employeeId: number) => {
setEmployeeToDelete(employeeId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = async () => {
if (!employeeToDelete) return;
setLoading(true);
try {
const deleteEmployeeResponse = await EmployeeApi.delete(employeeToDelete);
if (isResponseError(deleteEmployeeResponse)) {
console.error(
'Error deleting employee:',
deleteEmployeeResponse.message
);
toast.error('Gagal menghapus ABK');
return;
}
refreshEmployees();
toast.success('ABK berhasil dihapus');
setShowDeleteConfirm(false);
setEmployeeToDelete(null);
// await fetchEmployees();
} catch (error) {
console.error('Error deleting employee:', error);
toast.error('Terjadi kesalahan saat menghapus ABK');
} finally {
setLoading(false);
}
};
const handleExport = (format: string) => {
toast.success(`Data berhasil diekspor ke ${format}`);
};
if (isLoadingEmployees && !employees) {
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Employee (ABK)
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data {' '}
<span className='text-[#0069e0]'>Employee (ABK)</span>
</p>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Memuat data...
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Employee (ABK)
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data <span className='text-[#0069e0]'>Employee (ABK)</span>
</p>
</div>
{/* Main Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-0'>
{/* Single Toolbar Row */}
<div className='flex flex-wrap items-center justify-between gap-4 p-6 border-b border-gray-200/60'>
{/* LEFT: Search + Filters */}
<div className='flex items-center gap-3 flex-wrap'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4' />
<DebouncedTextInput
name='search'
placeholder='Cari nama ABK atau kandang...'
value={tableFilterState.search}
onChange={(e) => updateFilter('search', e.target.value)}
className={{
wrapper: 'w-full sm:w-[280px] border-gray-200',
inputWrapper: 'px-3 py-2 h-fit rounded-md',
input: 'text-sm',
}}
startAdornment={
<Search className='text-gray-400 w-4 h-4' />
}
/>
</div>
<Select
value={tableFilterState.kandang_id}
onValueChange={(value) =>
updateFilter('kandang_id', value === 'all' ? '' : value)
}
>
<SelectTrigger className='w-[180px] border-gray-200'>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
key={kandang.value}
value={String(kandang.value)}
>
{kandang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={tableFilterState.status}
onValueChange={(value) => {
updateFilter('status', value === 'all' ? '' : value);
}}
>
<SelectTrigger className='w-[160px] border-gray-200'>
<SelectValue placeholder='Semua Status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>Semua Status</SelectItem>
<SelectItem value='true'>Active</SelectItem>
<SelectItem value='false'>Non Active</SelectItem>
</SelectContent>
</Select>
</div>
{/* RIGHT: Export + Add */}
<div className='flex items-center gap-2 flex-wrap'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
className='border-gray-200 text-gray-700'
>
<Download className='w-4 h-4 mr-2' />
Export
<ChevronDown className='w-4 h-4 ml-2' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => handleExport('CSV')}>
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('Excel')}>
Export Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={handleAdd}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
<Plus className='w-4 h-4 mr-2' />
Tambah ABK
</Button>
</div>
</div>
{/* Table */}
<Table<Employee>
data={isResponseSuccess(employees) ? employees?.data : []}
columns={employeeColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={isResponseSuccess(employees) ? employees?.meta?.page : 0}
totalItems={
isResponseSuccess(employees)
? employees?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingEmployees}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(employees) &&
employees?.data?.length === 0,
}),
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-none',
headerRowClassName: 'bg-gray-50/50',
headerColumnClassName:
'text-left py-3.5 px-6 text-sm font-semibold text-gray-700',
paginationClassName: 'px-4',
}}
/>
</CardContent>
</Card>
</div>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>
{modalMode === 'create' ? 'Tambah ABK' : 'Edit ABK'}
</DialogTitle>
<DialogDescription>
{modalMode === 'create'
? 'Masukkan detail ABK baru'
: 'Ubah detail ABK'}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div>
<Label htmlFor='nama-abk'>
Nama ABK <span className='text-red-500'>*</span>
</Label>
<Input
id='nama-abk'
value={employeeForm.name}
onChange={(e) =>
setEmployeeForm({ ...employeeForm, name: e.target.value })
}
placeholder='Masukkan nama ABK'
className='mt-1.5'
disabled={loading}
/>
</div>
<div>
<Label htmlFor='kandang'>
Kandang <span className='text-red-500'>*</span>
</Label>
<MultiSelect
options={kandangOptions.map((k) => ({
value: String(k.value),
label: k.label,
}))}
selected={employeeForm.kandang_ids.map((id) => String(id))}
onChange={(selected) =>
setEmployeeForm({
...employeeForm,
kandang_ids: selected.map((id) => Number(id)),
})
}
placeholder='Pilih kandang'
className='mt-1.5'
/>
</div>
<div>
<Label htmlFor='status'>
Status <span className='text-red-500'>*</span>
</Label>
<Select
value={employeeForm.status}
onValueChange={(value: 'Active' | 'Non Active') =>
setEmployeeForm({ ...employeeForm, status: value })
}
disabled={loading}
>
<SelectTrigger id='status' className='mt-1.5'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='Active'>
<div className='flex items-center'>
<Badge variant='success' className='mr-2'>
Active
</Badge>
</div>
</SelectItem>
<SelectItem value='Non Active'>
<div className='flex items-center'>
<Badge variant='secondary' className='mr-2'>
Non Active
</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowModal(false)}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSave}
disabled={loading}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
{loading ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Hapus ABK?</AlertDialogTitle>
<AlertDialogDescription>
Data ABK akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={loading}
className='bg-red-600 hover:bg-red-700 text-white'
>
{loading ? 'Menghapus...' : 'Hapus'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,589 @@
'use client';
import { useState, useEffect } from 'react';
import { Eye, Download, Search } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge';
import { Input } from '@/figma-make/components/base/input';
import { Label } from '@/figma-make/components/base/label';
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import { toast } from 'sonner';
import { supabase, isSupabaseConfigured } from '@/figma-make/lib/supabase';
import { useRouter } from 'next/navigation';
interface SubmissionReportItem {
checklist_id: string;
date: string;
kandang_id: string;
kandang_name: string;
category: string;
status: string;
progress_percent: number;
total_phases: number;
total_activities: number;
total_employees: number;
updated_at: string;
}
interface Kandang {
id: string;
name: string;
}
interface ReportQueryResult {
id: string;
date: string;
kandang_id: string;
category: string;
status: string;
updated_at: string;
kandang: {
id: string;
name: string;
} | null;
}
const STATUS_OPTIONS = [
{ value: 'ALL', label: 'Semua Status' },
{ value: 'DRAFT', label: 'Draft' },
{ value: 'SUBMITTED', label: 'Submitted' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' },
];
const CATEGORY_LABELS: { [key: string]: string } = {
pullet_open: 'Pullet Open',
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
};
export function DailyChecklistReportsContent() {
const router = useRouter();
const [loading, setLoading] = useState(true);
// Report State
const [reportList, setReportList] = useState<SubmissionReportItem[]>([]);
const [filteredReportList, setFilteredReportList] = useState<
SubmissionReportItem[]
>([]);
// Master data
const [kandangList, setKandangList] = useState<Kandang[]>([]);
// Filters
const [statusFilter, setStatusFilter] = useState('ALL');
const [kandangFilter, setKandangFilter] = useState('ALL');
const [searchText, setSearchText] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
useEffect(() => {
fetchKandangList();
fetchReports();
}, []);
useEffect(() => {
applyFilters();
}, [reportList, statusFilter, kandangFilter, searchText, dateFrom, dateTo]);
const fetchKandangList = async () => {
if (!isSupabaseConfigured()) return;
try {
const { data, error } = await supabase
.from('kandang')
.select('id, name')
.order('name', { ascending: true });
if (error) {
console.error('Error fetching kandang:', error);
return;
}
setKandangList(data || []);
} catch (error) {
console.error('Error fetching kandang:', error);
}
};
const fetchReports = async () => {
if (!isSupabaseConfigured()) {
console.warn('Supabase not configured');
setLoading(false);
return;
}
try {
setLoading(true);
// Fetch checklists directly from daily_checklists table
const { data: checklists, error } = await supabase
.from('daily_checklists')
.select(
`
id,
date,
kandang_id,
category,
status,
updated_at,
kandang:kandang_id (
id,
name
)
`
)
.order('date', { ascending: false })
.order('updated_at', { ascending: false });
if (error) {
console.error('Error fetching reports:', error);
toast.error('Gagal memuat data reports');
return;
}
// Enrich data with calculations
const enrichedData = await Promise.all(
((checklists as unknown as ReportQueryResult[]) || [])
.filter((checklist) => checklist.id)
.map(async (checklist) => {
// Count phases
const { count: phaseCount } = await supabase
.from('daily_checklist_phases')
.select('*', { count: 'exact', head: true })
.eq('checklist_id', checklist.id);
// Count activities (tasks)
const { count: activityCount } = await supabase
.from('daily_checklist_activity_tasks')
.select('*', { count: 'exact', head: true })
.eq('checklist_id', checklist.id);
// Count unique employees
const { data: tasks } = await supabase
.from('daily_checklist_activity_tasks')
.select('id')
.eq('checklist_id', checklist.id);
const taskIds = (tasks || []).map((t) => t.id);
let uniqueEmployees = new Set<string>();
if (taskIds.length > 0) {
const { data: assignments } = await supabase
.from('daily_checklist_activity_task_assignments')
.select('employee_id')
.in('task_id', taskIds);
uniqueEmployees = new Set(
(assignments || []).map((a) => a.employee_id)
);
}
// ✅ Calculate progress based on phase coverage
const { count: totalPhasesInMaster } = await supabase
.from('phases')
.select('*', { count: 'exact', head: true })
.eq('category_id', checklist.category);
const { data: checklistTasks } = await supabase
.from('daily_checklist_activity_tasks')
.select('id, phase_id')
.eq('checklist_id', checklist.id);
const checklistTaskIds = (checklistTasks || []).map((t) => t.id);
const uniquePhasesWithChecked = new Set<string>();
if (checklistTaskIds.length > 0) {
const { data: checkedAssignments } = await supabase
.from('daily_checklist_activity_task_assignments')
.select('task_id')
.in('task_id', checklistTaskIds)
.eq('checked', true);
if (checkedAssignments && checkedAssignments.length > 0) {
const checkedTaskIds = new Set(
checkedAssignments.map((a) => a.task_id)
);
checklistTasks?.forEach((task) => {
if (checkedTaskIds.has(task.id)) {
uniquePhasesWithChecked.add(task.phase_id);
}
});
}
}
const phasesWithCheckedCount = uniquePhasesWithChecked.size;
const progressPercent =
totalPhasesInMaster && totalPhasesInMaster > 0
? Math.round(
(phasesWithCheckedCount / totalPhasesInMaster) * 100
)
: 0;
return {
checklist_id: checklist.id,
date: checklist.date,
kandang_id: checklist.kandang_id,
kandang_name: checklist.kandang?.name || '-',
category: checklist.category,
status: checklist.status,
progress_percent: progressPercent,
total_phases: phaseCount || 0,
total_activities: activityCount || 0,
total_employees: uniqueEmployees.size,
updated_at: checklist.updated_at,
};
})
);
setReportList(enrichedData);
} catch (error) {
console.error('Error fetching reports:', error);
toast.error('Terjadi kesalahan');
} finally {
setLoading(false);
}
};
const applyFilters = () => {
let filtered = [...reportList];
if (statusFilter && statusFilter !== 'ALL') {
filtered = filtered.filter((item) => item.status === statusFilter);
}
if (kandangFilter && kandangFilter !== 'ALL') {
filtered = filtered.filter((item) => item.kandang_id === kandangFilter);
}
if (searchText) {
filtered = filtered.filter(
(item) =>
item.kandang_name.toLowerCase().includes(searchText.toLowerCase()) ||
item.category.toLowerCase().includes(searchText.toLowerCase()) ||
(CATEGORY_LABELS[item.category] || '')
.toLowerCase()
.includes(searchText.toLowerCase())
);
}
if (dateFrom) {
filtered = filtered.filter(
(item) => new Date(item.date) >= new Date(dateFrom)
);
}
if (dateTo) {
filtered = filtered.filter(
(item) => new Date(item.date) <= new Date(dateTo)
);
}
setFilteredReportList(filtered);
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'DRAFT':
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
Draft
</Badge>
);
case 'SUBMITTED':
return (
<Badge
variant='outline'
className='border-orange-300 text-orange-700 bg-white'
>
Submitted
</Badge>
);
case 'APPROVED':
return (
<Badge
variant='outline'
className='border-green-300 text-green-700 bg-white'
>
Approved
</Badge>
);
case 'REJECTED':
return (
<Badge
variant='outline'
className='border-red-300 text-red-700 bg-white'
>
Rejected
</Badge>
);
default:
return (
<Badge
variant='outline'
className='border-gray-300 text-gray-700 bg-white'
>
{status}
</Badge>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleViewDetail = (checklistId: string) => {
// Navigate to detail page (same as List Daily Checklist)
router.push(`/list-daily-checklist/detail?checklistId=${checklistId}`);
};
const exportToCSV = () => {
toast.info('Export CSV akan segera tersedia');
};
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-semibold text-gray-900'>Reports</h1>
<p className='text-sm text-gray-600 mt-1'>
Laporan lengkap checklist harian
</p>
</div>
<Button
onClick={exportToCSV}
className='bg-[#0069e0] hover:bg-[#0058c0] text-white'
>
<Download className='w-4 h-4 mr-2' />
Export CSV
</Button>
</div>
{/* Main Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-6'>
{/* Filters Section */}
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 pb-6 border-b border-gray-200'>
<div>
<Label>Periode Tanggal</Label>
<div className='mt-1.5'>
<DateRangePicker
dateFrom={dateFrom}
dateTo={dateTo}
onDateChange={(from, to) => {
setDateFrom(from);
setDateTo(to);
}}
/>
</div>
</div>
<div>
<Label htmlFor='kandang-filter-report'>Kandang</Label>
<Select value={kandangFilter} onValueChange={setKandangFilter}>
<SelectTrigger
id='kandang-filter-report'
className='mt-1.5 border-gray-200'
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangList.map((kandang) => (
<SelectItem key={kandang.id} value={kandang.id}>
{kandang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor='status-filter-report'>Status</Label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger
id='status-filter-report'
className='mt-1.5 border-gray-200'
>
<SelectValue placeholder='Semua Status' />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor='search-text-report'>Cari</Label>
<div className='relative mt-1.5'>
<Input
id='search-text-report'
type='text'
placeholder='Kandang / Kategori...'
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className='border-gray-200 pl-9'
/>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
</div>
</div>
</div>
{/* Reports Table */}
{loading ? (
<div className='text-center py-12 text-gray-500'>
Memuat data...
</div>
) : filteredReportList.length > 0 ? (
<div className='overflow-x-auto'>
<table className='w-full border border-gray-200 rounded-lg'>
<thead>
<tr className='bg-gray-50 border-b border-gray-200'>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Tanggal
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Kandang
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Kategori
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Status
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Phase
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Aktivitas
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
ABK
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Progress
</th>
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700'>
Updated At
</th>
<th className='text-center py-3 px-4 text-sm font-semibold text-gray-700'>
Aksi
</th>
</tr>
</thead>
<tbody>
{filteredReportList.map((item, index) => (
<tr
key={`${item.checklist_id}-${item.date}-${index}`}
className={
index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'
}
>
<td className='py-3 px-4 text-sm text-gray-900'>
{formatDate(item.date)}
</td>
<td className='py-3 px-4 text-sm text-gray-900'>
{item.kandang_name}
</td>
<td className='py-3 px-4 text-sm text-gray-900'>
{CATEGORY_LABELS[item.category] || item.category}
</td>
<td className='py-3 px-4'>
{getStatusBadge(item.status)}
</td>
<td className='py-3 px-4 text-center text-sm text-gray-900'>
{item.total_phases}
</td>
<td className='py-3 px-4 text-center text-sm text-gray-900'>
{item.total_activities}
</td>
<td className='py-3 px-4 text-center text-sm text-gray-900'>
{item.total_employees}
</td>
<td className='py-3 px-4 text-center'>
<div className='flex items-center justify-center gap-2'>
<div className='w-20 bg-gray-200 rounded-full h-2'>
<div
className='bg-[#0069e0] h-2 rounded-full transition-all'
style={{ width: `${item.progress_percent}%` }}
/>
</div>
<span className='text-sm text-gray-700 font-medium'>
{item.progress_percent}%
</span>
</div>
</td>
<td className='py-3 px-4 text-sm text-gray-600'>
{formatDateTime(item.updated_at)}
</td>
<td className='py-3 px-4'>
<div className='flex items-center justify-center'>
<Button
size='sm'
variant='outline'
onClick={() =>
handleViewDetail(item.checklist_id)
}
className='border-gray-200 text-gray-700 hover:bg-gray-50'
>
<Eye className='w-4 h-4 mr-1' />
Detail
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className='text-center py-12 text-gray-500'>
{searchText ||
dateFrom ||
dateTo ||
statusFilter !== 'ALL' ||
kandangFilter !== 'ALL'
? 'Tidak ada data yang sesuai dengan filter'
: 'Belum ada data checklist'}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
// TODO: delete this file later
/* AUTOGENERATED FILE - DO NOT EDIT CONTENTS */
export const projectId = 'xxx';
export const publicAnonKey = 'xxx';
+339
View File
@@ -0,0 +1,339 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { projectId, publicAnonKey } from '@/figma-make/lib/info';
// ============================================
// 🔍 SUPABASE ENVIRONMENT DEBUG CHECK
// ============================================
/**
* Get environment variable from multiple sources
* Checks in order: __ENV__, window.__ENV__, process.env, import.meta.env
*/
function getEnv(key: string): string | undefined {
let value: string | undefined;
let source: string | undefined;
// Check globalThis.__ENV__
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((globalThis as any).__ENV__?.[key]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (globalThis as any).__ENV__[key];
source = 'globalThis.__ENV__';
}
// Check window.__ENV__
// eslint-disable-next-line @typescript-eslint/no-explicit-any
else if (typeof window !== 'undefined' && (window as any).__ENV__?.[key]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (window as any).__ENV__[key];
source = 'window.__ENV__';
}
// Check process.env
// eslint-disable-next-line @typescript-eslint/no-explicit-any
else if ((globalThis as any).process?.env?.[key]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (globalThis as any).process.env[key];
source = 'process.env';
}
// Check import.meta.env (if available)
else if (
typeof import.meta !== 'undefined' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(import.meta as any)?.env?.[key]
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (import.meta as any).env[key];
source = 'import.meta.env';
}
if (value && source) {
console.log(`${key} loaded from: ${source}`);
}
return value;
}
// Try to read from environment variables first
let supabaseUrl = getEnv('VITE_SUPABASE_URL');
let supabaseAnonKey = getEnv('VITE_SUPABASE_ANON_KEY');
// Fallback to Figma Make autogenerated credentials
if (!supabaseUrl || !supabaseAnonKey) {
console.log(
'📋 Using Figma Make autogenerated Supabase credentials from /utils/supabase/info.tsx'
);
supabaseUrl = `https://${projectId}.supabase.co`;
supabaseAnonKey = publicAnonKey;
}
// Helper function to mask sensitive data
const maskString = (str: string | undefined): string => {
if (!str) return 'undefined';
if (str.length <= 20) return str.substring(0, 10) + '...';
return str.substring(0, 20) + '...' + `(${str.length - 20} chars masked)`;
};
// Debug logging
console.group('🔍 Supabase Environment Check');
console.log('projectId (from info.tsx):', projectId);
console.log('SUPABASE_URL present?', !!supabaseUrl);
console.log('SUPABASE_KEY present?', !!supabaseAnonKey);
console.log('SUPABASE_URL value:', maskString(supabaseUrl));
console.log('SUPABASE_KEY value:', maskString(supabaseAnonKey));
console.groupEnd();
// Check if Supabase is configured
export const isSupabaseConfigured = () => {
return !!(supabaseUrl && supabaseAnonKey);
};
// Create Supabase client or throw error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let supabase: SupabaseClient<any>;
if (isSupabaseConfigured()) {
console.log('✅ Creating real Supabase client...');
supabase = createClient<Database>(supabaseUrl!, supabaseAnonKey!);
console.log('✅ Supabase client created successfully!');
} else {
const errorMessage = `
SUPABASE CONFIGURATION ERROR
Missing required environment variables:
- VITE_SUPABASE_URL: ${!!supabaseUrl ? '✅ Present' : '❌ Missing'}
- VITE_SUPABASE_ANON_KEY: ${!!supabaseAnonKey ? '✅ Present' : '❌ Missing'}
Please set Supabase environment variables in:
Figma Make Supabase integration settings
Deployment settings/environment configuration
The app checked the following sources:
- globalThis.__ENV__
- window.__ENV__
- process.env
- import.meta.env
None of these sources contained the required variables.
`.trim();
console.error(errorMessage);
throw new Error(errorMessage);
}
export { supabase };
// Database types
export interface Database {
public: {
Tables: {
kandang: {
Row: {
id: string;
name: string;
created_at?: string;
};
Insert: {
id?: string;
name: string;
created_at?: string;
};
Update: {
id?: string;
name?: string;
created_at?: string;
};
};
employees: {
Row: {
id: string;
name: string;
kandang_id: string;
is_active: boolean;
created_at?: string;
};
Insert: {
id?: string;
name: string;
kandang_id: string;
is_active?: boolean;
created_at?: string;
};
Update: {
id?: string;
name?: string;
kandang_id?: string;
is_active?: boolean;
created_at?: string;
};
};
phases: {
Row: {
id: string;
name: string;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
name: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
name?: string;
created_at?: string;
updated_at?: string;
};
};
phase_activities: {
Row: {
id: string;
phase_id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
phase_id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
phase_id?: string;
name?: string;
description?: string;
created_at?: string;
updated_at?: string;
};
};
checklists: {
Row: {
id: string;
name: string;
description?: string;
phase_id: string;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
name: string;
description?: string;
phase_id: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
name?: string;
description?: string;
phase_id?: string;
created_at?: string;
updated_at?: string;
};
};
daily_checklists: {
Row: {
id: string;
date: string;
kandang_id: string;
checklist_id: string;
category: string;
status: string;
name?: string;
total_score?: number;
document_path?: string;
reject_reason?: string;
created_by: string;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
date: string;
kandang_id: string;
checklist_id: string;
category: string;
status?: string;
name?: string;
total_score?: number;
document_path?: string;
reject_reason?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
date?: string;
kandang_id?: string;
checklist_id?: string;
category?: string;
status?: string;
name?: string;
total_score?: number;
document_path?: string;
reject_reason?: string;
created_at?: string;
updated_at?: string;
};
};
daily_checklist_tasks: {
Row: {
id: string;
checklist_id: string;
activity_id: string;
notes?: string;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
checklist_id: string;
activity_id: string;
notes?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
checklist_id?: string;
activity_id?: string;
notes?: string;
created_at?: string;
updated_at?: string;
};
};
task_assignees: {
Row: {
id: string;
task_id: string;
employee_id: string;
is_completed: boolean;
created_at?: string;
updated_at?: string;
};
Insert: {
id?: string;
task_id: string;
employee_id: string;
is_completed?: boolean;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
task_id?: string;
employee_id?: string;
is_completed?: boolean;
created_at?: string;
updated_at?: string;
};
};
};
};
}
+249
View File
@@ -0,0 +1,249 @@
@custom-variant dark (&:is(.dark *));
:root {
--font-size: 16px;
/* Commented out to avoid conflict with DaisyUI - Core Colors & Radius
--background: #ffffff;
--foreground: #111827;
*/
--card: #ffffff;
--card-foreground: #111827;
--popover: #ffffff;
--popover-foreground: #111827;
/*
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #f3f4f6;
--secondary-foreground: #111827;
*/
--muted: #f9fafb;
--muted-foreground: #6b7280;
/*
--accent: #eff6ff;
--accent-foreground: #2563eb;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e5e7eb;
--input: transparent;
--input-background: #f9fafb;
--switch-background: #d1d5db;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #2563eb;
*/
--chart-1: #2563eb;
--chart-2: #10b981;
--chart-3: #f59e0b;
--chart-4: #8b5cf6;
--chart-5: #ef4444;
/* --radius: 0.5rem; */
--radius: 0.5rem; /* Kept for compatibility if needed, but watch multiple sources */
--sidebar: #ffffff;
--sidebar-foreground: #111827;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #eff6ff;
--sidebar-accent-foreground: #2563eb;
--sidebar-border: #e5e7eb;
--sidebar-ring: #2563eb;
}
.dark {
/*
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
*/
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
/*
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
*/
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
/*
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
*/
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/*
--color-background: var(--background);
--color-foreground: var(--foreground);
*/
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
/*
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
*/
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
/*
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
*/
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/*
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
*/
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
/* Commented out to avoid conflict with DaisyUI
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/**
* Default typography styles for HTML elements (h1-h4, p, label, button, input).
* These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
*
html {
font-size: var(--font-size);
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
label {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: var(--text-base);
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
*/
/* Custom Checkbox Styling - Clean ERP Style */
input[type='checkbox'].checkbox-clean {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 1.5px solid #d1d5db;
border-radius: 4px;
background-color: #ffffff;
cursor: pointer;
position: relative;
transition: all 150ms ease-in-out;
flex-shrink: 0;
}
input[type='checkbox'].checkbox-clean:hover:not(:disabled) {
border-color: #9ca3af;
}
input[type='checkbox'].checkbox-clean:checked {
background-color: #0069e0;
border-color: #0069e0;
}
input[type='checkbox'].checkbox-clean:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid #ffffff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type='checkbox'].checkbox-clean:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 105, 224, 0.25);
}
input[type='checkbox'].checkbox-clean:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@@ -0,0 +1,200 @@
import axios from 'axios';
import { BaseApiService } from '@/services/api/base';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import {
CreateDailyChecklistPayload,
DailyChecklist,
DetailDailyChecklist,
} from '@/types/api/daily-checklist/daily-checklist';
export class DailyChecklistApiService extends BaseApiService<
DailyChecklist,
CreateDailyChecklistPayload,
unknown
> {
constructor(basePath: string = '/daily-checklists') {
super(basePath);
}
async getOneDailyChecklist(id: string) {
try {
const getOneDailyChecklistPath = `${this.basePath}/relation/${id}`;
const getOneDailyChecklistRes = await httpClient<
BaseApiResponse<DetailDailyChecklist>
>(getOneDailyChecklistPath);
return getOneDailyChecklistRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<DetailDailyChecklist>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async setDailyChecklistPhase(id: string, phaseIds: string[]) {
try {
const setDailyChecklistPhasePath = `${this.basePath}/phase/${id}`;
const setDailyChecklistPhaseRes = await httpClient<BaseApiResponse>(
setDailyChecklistPhasePath,
{
method: 'POST',
body: { phase_ids: phaseIds.join(',') },
}
);
return setDailyChecklistPhaseRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async removeEmployeeAssignment(id: string, employeeId: string) {
try {
const removeEmployeeAssignmentPath = `${this.basePath}/${id}/assignments/${employeeId}`;
const removeEmployeeAssignmentRes = await httpClient<BaseApiResponse>(
removeEmployeeAssignmentPath,
{
method: 'DELETE',
}
);
return removeEmployeeAssignmentRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async setDailyChecklistEmployees(checklistId: string, employeeIds: string[]) {
try {
const setDailyChecklistPhasePath = `${this.basePath}/assignment/${checklistId}`;
const setDailyChecklistPhaseRes = await httpClient<BaseApiResponse>(
setDailyChecklistPhasePath,
{
method: 'POST',
body: { employee_ids: employeeIds.join(',') },
}
);
return setDailyChecklistPhaseRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getTasksByChecklistId(id: string) {
try {
const getTasksByChecklistIdPath = `${this.basePath}/tasks?checklist_id=${id}&page=1&limit=100`;
const getTasksByChecklistIdRes = await httpClient<BaseApiResponse>(
getTasksByChecklistIdPath
);
return getTasksByChecklistIdRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async checkOrUncheckAssignment(payload: {
task_id: number;
employee_id: number;
checked: boolean;
note: string | null;
}) {
try {
const checkOrUncheckAssignmentPath = `${this.basePath}/assignment`;
const checkOrUncheckAssignmentRes = await httpClient<BaseApiResponse>(
checkOrUncheckAssignmentPath,
{
method: 'POST',
body: payload,
}
);
return checkOrUncheckAssignmentRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async submit(id: string) {
try {
const submitPath = `${this.basePath}/${id}`;
const submitRes = await httpClient<BaseApiResponse>(submitPath, {
method: 'PATCH',
body: {
status: 'SUBMITTED',
reject_reason: '',
},
});
return submitRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async approve(id: string) {
try {
const approvePath = `${this.basePath}/${id}`;
const approveRes = await httpClient<BaseApiResponse>(approvePath, {
method: 'PATCH',
body: {
status: 'APPROVED',
reject_reason: '',
},
});
return approveRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async reject(id: string, rejectReason: string) {
try {
const rejectPath = `${this.basePath}/${id}`;
const rejectRes = await httpClient<BaseApiResponse>(rejectPath, {
method: 'PATCH',
body: {
status: 'REJECTED',
reject_reason: rejectReason,
},
});
return rejectRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
}
export const DailyChecklistApi = new DailyChecklistApiService(
'/daily-checklists'
);
@@ -0,0 +1,18 @@
import { BaseApiService } from '@/services/api/base';
import {
CreateEmployeePayload,
Employee,
UpdateEmployeePayload,
} from '@/types/api/daily-checklist/employee';
export class EmployeeApiService extends BaseApiService<
Employee,
CreateEmployeePayload,
UpdateEmployeePayload
> {
constructor(basePath: string = '/master-data/employees') {
super(basePath);
}
}
export const EmployeeApi = new EmployeeApiService('/master-data/employees');

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