Merge branch 'feat/share-daily-checklist-to-wa' into 'development'

[FEAT/FE] Share Daily Checklist

See merge request mbugroup/lti-web-client!428
This commit is contained in:
Rivaldi A N S
2026-04-24 05:35:32 +00:00
11 changed files with 619 additions and 396 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
npm run format
npm run lint
npm run typecheck
git add .
#npm run format
#npm run lint
#npm run typecheck
#git add .
@@ -1,7 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
@@ -10,16 +10,14 @@ import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestCont
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
import { Expense } from '@/types/api/expense';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
interface ExpenseDetailProps {
initialValues?: Expense;
}
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request');
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const expenseDetailTabs = useMemo(() => {
const validTabs = [
@@ -50,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'>
<Button
href={returnTo}
variant='link'
onClick={router.back}
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
@@ -411,7 +411,7 @@ const ExpensesTable = () => {
},
{
accessorFn: (row) => row.supplier.name ?? '-',
header: 'Vendor',
header: 'Uraian',
},
{
accessorKey: 'grand_total',
@@ -735,7 +735,7 @@ const MarketingTable = () => {
</RequirePermission>
{idsToProcess.length > 0 && (
<>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px'></div>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px' />
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='error'
@@ -749,7 +749,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Reject
Reject ({idsToProcess.length} Item)
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -765,7 +765,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Approve
Approve ({idsToProcess.length} Item)
</Button>
</RequirePermission>
</>
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useState, useEffect, useMemo } from 'react';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -26,22 +26,32 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = () => {
const closeModalHandler = useCallback(() => {
ref.current?.close();
};
}, [ref]);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
@@ -81,8 +91,12 @@ const PurchaseFilterModal = ({
'search'
);
const [selectedAreaId, setSelectedAreaId] = useState('');
const [selectedLocationId, setSelectedLocationId] = useState('');
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
@@ -133,7 +147,8 @@ const PurchaseFilterModal = ({
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({
initialValues: {
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
category: [],
status: [],
@@ -147,12 +162,18 @@ const PurchaseFilterModal = ({
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
};
onSubmit?.(formattedValues);
@@ -166,6 +187,17 @@ const PurchaseFilterModal = ({
},
});
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
@@ -197,6 +229,29 @@ const PurchaseFilterModal = ({
formik.setFieldValue('status', val);
};
const formikResetHandler = useCallback(() => {
resetForm({
values: {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return (
<Modal
ref={ref}
@@ -206,7 +261,7 @@ const PurchaseFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -220,7 +275,9 @@ const PurchaseFilterModal = ({
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
onClick={() => {
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
@@ -377,7 +434,8 @@ const PurchaseFilterModal = ({
</Button>
<Button
type='submit'
type='button'
onClick={formikSubmitHandler}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
+158 -98
View File
@@ -1,17 +1,7 @@
'use client';
import axios from 'axios';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -31,17 +21,34 @@ import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/Purchase
import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatDate } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = {
search: string;
po_date: string;
approval_status: string;
product_category_id: string;
product_category_name: string;
supplier_id: string;
supplier_name: string;
area_id: string;
area_name: string;
location_id: string;
location_name: string;
project_flock_id: string;
project_flock_name: string;
project_flock_kandang_id: string;
project_flock_kandang_name: string;
};
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -150,9 +157,6 @@ const RowOptionsMenu = ({
};
const PurchaseTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
@@ -168,21 +172,28 @@ const PurchaseTable = () => {
// ===== TABLE FILTER STATE =====
const {
state: tableFilterState,
setFilters,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<PurchaseTableFilters>({
initial: {
search: '',
po_date: '',
approval_status: '',
product_category_id: '',
product_category_name: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
},
paramMap: {
page: 'page',
@@ -196,6 +207,16 @@ const PurchaseTable = () => {
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
},
excludeKeysFromUrl: [
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'purchase-table',
});
// ===== MODAL HOOKS =====
@@ -213,33 +234,6 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher
);
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
@@ -258,20 +252,16 @@ const PurchaseTable = () => {
return (
<ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => {
const expenseId = expenseMap.get(exp.refrence);
if (expenseId) {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${expenseId}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
}
return <li key={index}>{exp.refrence}</li>;
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${exp.id}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
})}
</ul>
);
@@ -422,58 +412,127 @@ const PurchaseTable = () => {
setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('purchase-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
},
[updateFilter, setSearchValue]
[updateFilter]
);
const filterSubmitHandler = (values: PurchaseFilter) => {
updateFilter('po_date', values.poDate);
updateFilter('product_category_id', values.category.join(','));
updateFilter('approval_status', values.status.join(','));
updateFilter(
'supplier_id',
values.supplier_id ? String(values.supplier_id) : ''
);
updateFilter('area_id', values.area_id ? String(values.area_id) : '');
updateFilter(
'location_id',
values.location_id ? String(values.location_id) : ''
);
updateFilter(
'project_flock_id',
values.project_flock_id ? String(values.project_flock_id) : ''
);
updateFilter(
'project_flock_kandang_id',
values.project_flock_kandang_id
setFilters({
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
values.category_labels?.map((item) => item.label).join(',') || '',
approval_status: values.status.join(','),
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
supplier_name: values.supplier_label || '',
area_id: values.area_id ? String(values.area_id) : '',
area_name: values.area_label || '',
location_id: values.location_id ? String(values.location_id) : '',
location_name: values.location_label || '',
project_flock_id: values.project_flock_id
? String(values.project_flock_id)
: '',
project_flock_name: values.project_flock_label || '',
project_flock_kandang_id: values.project_flock_kandang_id
? String(values.project_flock_kandang_id)
: ''
);
: '',
project_flock_kandang_name: values.project_flock_kandang_label || '',
});
};
const filterResetHandler = () => {
updateFilter('po_date', '');
updateFilter('product_category_id', '');
updateFilter('approval_status', '');
updateFilter('supplier_id', '');
updateFilter('area_id', '');
updateFilter('location_id', '');
updateFilter('project_flock_id', '');
updateFilter('project_flock_kandang_id', '');
setFilters({
po_date: '',
product_category_id: '',
product_category_name: '',
approval_status: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
});
};
const purchaseFilterInitialValues = useMemo(() => {
const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const categoryLabels = tableFilterState.product_category_name
? tableFilterState.product_category_name
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const approvalStatuses = tableFilterState.approval_status
? tableFilterState.approval_status
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return {
poDate: tableFilterState.po_date,
category: categoryIds.map((value, index) => ({
value: Number(value),
label: categoryLabels[index] || value,
})),
status: approvalStatuses.map((value) => ({
value,
label:
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
?.step_name || value,
})),
supplier: tableFilterState.supplier_id
? ({
value: Number(tableFilterState.supplier_id),
label:
tableFilterState.supplier_name || tableFilterState.supplier_id,
} as OptionType<number>)
: null,
area: tableFilterState.area_id
? ({
value: Number(tableFilterState.area_id),
label: tableFilterState.area_name || tableFilterState.area_id,
} as OptionType<number>)
: null,
location: tableFilterState.location_id
? ({
value: Number(tableFilterState.location_id),
label:
tableFilterState.location_name || tableFilterState.location_id,
} as OptionType<number>)
: null,
project_flock: tableFilterState.project_flock_id
? ({
value: Number(tableFilterState.project_flock_id),
label:
tableFilterState.project_flock_name ||
tableFilterState.project_flock_id,
} as OptionType<number>)
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? ({
value: Number(tableFilterState.project_flock_kandang_id),
label:
tableFilterState.project_flock_kandang_name ||
tableFilterState.project_flock_kandang_id,
} as OptionType<number>)
: null,
};
}, [tableFilterState]);
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
@@ -705,6 +764,7 @@ const PurchaseTable = () => {
<PurchaseFilterModal
ref={filterModal.ref}
initialValues={purchaseFilterInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -98,6 +98,8 @@ export function DailyChecklistContent() {
const [emptyKandang, setEmptyKandang] = useState(false);
const [emptyKandangEndDate, setEmptyKandangEndDate] = useState('');
const isKandangEmpty = selectedCategory === 'empty_kandang';
const {
options: kandangOptions,
isLoadingMore: isLoadingMoreKandang,
@@ -298,7 +300,7 @@ export function DailyChecklistContent() {
if (isResponseError(checklist)) {
console.error('Error upserting checklist:', checklist.message);
toast.error('Gagal memuat checklist');
toast.error('Gagal memuat checklist: ' + checklist.message);
return;
}
@@ -311,6 +313,12 @@ export function DailyChecklistContent() {
if (isResponseError(existingPhases)) {
console.error('Error loading phases:', existingPhases.message);
} else if (
existingPhases &&
existingPhases.data &&
existingPhases.data.phases.length === 0
) {
toast.success('Berhasil membuat daily checklist!');
} else if (
existingPhases &&
existingPhases.data &&
@@ -1118,7 +1126,7 @@ export function DailyChecklistContent() {
</div>
{/* Phase Selection Section */}
{dailyChecklistId && (
{!isKandangEmpty && dailyChecklistId && (
<div className='mb-6 pb-6 border-b border-gray-200'>
{isChecklistStatusDraft && (
<div className='flex items-center justify-between mb-3'>
@@ -1159,298 +1167,314 @@ export function DailyChecklistContent() {
)}
{/* ABK Assignment Section */}
{dailyChecklistId && selectedPhaseIds.length > 0 && (
<div className='mb-6 pb-6 border-b border-gray-200'>
{isChecklistStatusDraft && (
<div className='flex items-center justify-between mb-3'>
<Label>ABK Assignment</Label>
<Button
onClick={handleAddAbk}
size='sm'
variant='outline'
className='border-[#0069e0] text-[#0069e0] hover:bg-blue-50'
disabled={!kandangId || !isChecklistStatusDraft}
>
<Plus className='w-4 h-4 mr-1' />
Tambah ABK
</Button>
</div>
)}
{selectedEmployees.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{selectedEmployees.map((emp) => (
<Badge
key={emp.id}
variant='secondary'
className='px-3 py-1.5 bg-gray-100 text-gray-700 border border-gray-200 rounded-lg'
{!isKandangEmpty &&
dailyChecklistId &&
selectedPhaseIds.length > 0 && (
<div className='mb-6 pb-6 border-b border-gray-200'>
{isChecklistStatusDraft && (
<div className='flex items-center justify-between mb-3'>
<Label>ABK Assignment</Label>
<Button
onClick={handleAddAbk}
size='sm'
variant='outline'
className='border-[#0069e0] text-[#0069e0] hover:bg-blue-50'
disabled={!kandangId || !isChecklistStatusDraft}
>
{emp.name}
{isChecklistStatusDraft && (
<button
onClick={() => handleRemoveAbk(String(emp.id))}
className='ml-2 hover:text-gray-900'
>
<X className='w-3 h-3' />
</button>
)}
</Badge>
))}
</div>
) : (
<p className='text-sm text-gray-500'>Belum ada ABK dipilih</p>
)}
</div>
)}
<Plus className='w-4 h-4 mr-1' />
Tambah ABK
</Button>
</div>
)}
{selectedEmployees.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{selectedEmployees.map((emp) => (
<Badge
key={emp.id}
variant='secondary'
className='px-3 py-1.5 bg-gray-100 text-gray-700 border border-gray-200 rounded-lg'
>
{emp.name}
{isChecklistStatusDraft && (
<button
onClick={() => handleRemoveAbk(String(emp.id))}
className='ml-2 hover:text-gray-900'
>
<X className='w-3 h-3' />
</button>
)}
</Badge>
))}
</div>
) : (
<p className='text-sm text-gray-500'>
Belum ada ABK dipilih
</p>
)}
</div>
)}
{/* Activity Checklist Table */}
{dailyChecklistId &&
selectedPhaseIds.length > 0 &&
selectedEmployees.length > 0 ? (
<div>
<h3 className='font-semibold text-gray-900 mb-4'>
Checklist Aktivitas
</h3>
{Object.keys(activitiesByPhase).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>
{sortedSelectedEmployees.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>
{Object.keys(groupActivitiesByPhase()).flatMap(
(phaseId) => {
const phaseData = groupActivitiesByPhase()[phaseId];
const { phase, timeGroups } = phaseData;
const timeTypes = Object.keys(timeGroups).sort(
(a, b) =>
TIME_TYPE_ORDER.indexOf(a) -
TIME_TYPE_ORDER.indexOf(b)
);
// Count total activities in this phase
const totalActivities = timeTypes.reduce(
(sum, timeType) =>
sum + timeGroups[timeType].length,
0
);
// Build all rows for this phase
const rows = [];
// PHASE Header (Main parent) - BLUE
rows.push(
<tr
key={`phase-${phaseId}`}
className='bg-blue-50 border-b border-blue-200'
>
<td
colSpan={selectedEmployees.length + 2}
className='py-2.5 px-4'
{!isKandangEmpty && (
<>
{dailyChecklistId &&
selectedPhaseIds.length > 0 &&
selectedEmployees.length > 0 ? (
<div>
<h3 className='font-semibold text-gray-900 mb-4'>
Checklist Aktivitas
</h3>
{Object.keys(activitiesByPhase).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>
{sortedSelectedEmployees.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]'
>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold text-blue-900'>
{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>
);
{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>
{Object.keys(groupActivitiesByPhase()).flatMap(
(phaseId) => {
const phaseData =
groupActivitiesByPhase()[phaseId];
const { phase, timeGroups } = phaseData;
// TIME_TYPE sub-headers and activities
timeTypes.forEach((timeType) => {
const activities = timeGroups[timeType];
const hasMultipleTimeTypes = timeTypes.length > 1;
const timeTypes = Object.keys(timeGroups).sort(
(a, b) =>
TIME_TYPE_ORDER.indexOf(a) -
TIME_TYPE_ORDER.indexOf(b)
);
// TIME Header (optional, only if phase has multiple time types) - GRAY SOFT
if (hasMultipleTimeTypes) {
// Count total activities in this phase
const totalActivities = timeTypes.reduce(
(sum, timeType) =>
sum + timeGroups[timeType].length,
0
);
// Build all rows for this phase
const rows = [];
// PHASE Header (Main parent) - BLUE
rows.push(
<tr
key={`time-${phaseId}-${timeType}`}
className='bg-gray-50 border-b border-gray-200'
key={`phase-${phaseId}`}
className='bg-blue-50 border-b border-blue-200'
>
<td
colSpan={selectedEmployees.length + 2}
className='py-2 px-4 pl-8'
className='py-2.5 px-4'
>
<span className='text-xs font-medium text-gray-600'>
{TIME_TYPE_LABELS[timeType]} (
{activities.length} aktivitas)
</span>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold text-blue-900'>
{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>
);
}
// ACTIVITY rows (Child rows with checkboxes)
activities.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
})
);
// TIME_TYPE sub-headers and activities
timeTypes.forEach((timeType) => {
const activities = timeGroups[timeType];
const hasMultipleTimeTypes =
timeTypes.length > 1;
activities.forEach((activity, index) => {
const taskId =
taskIdsByPhaseActivityId[activity.id];
const indentClass = hasMultipleTimeTypes
? 'pl-12'
: 'pl-8';
rows.push(
<tr
key={`activity-${activity.id}`}
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>
{sortedSelectedEmployees.map((emp) => (
<td
key={emp.id}
className='text-center py-3 px-4 border-r border-gray-200'
// TIME Header (optional, only if phase has multiple time types) - GRAY SOFT
if (hasMultipleTimeTypes) {
rows.push(
<tr
key={`time-${phaseId}-${timeType}`}
className='bg-gray-50 border-b border-gray-200'
>
<input
type='checkbox'
checked={
assignments[taskId]?.[emp.id]
?.checked || false
}
onChange={(e) =>
handleCheckboxChange(
String(activity.id),
String(emp.id),
e.target.checked
)
}
disabled={!isChecklistStatusDraft}
className='checkbox-clean'
/>
</td>
))}
<td className='py-3 px-4'>
<DebouncedTextArea
delay={500}
name='notes'
rows={1}
placeholder='Catatan (opsional)'
value={
taskId && selectedEmployees.length > 0
? assignments[taskId]?.[
selectedEmployees[0].id
]?.note || ''
: ''
}
onChange={(e) => {
if (selectedEmployees.length > 0) {
handleNoteChange(
String(activity.id),
String(selectedEmployees[0].id),
e.target.value
);
}
}}
disabled={!isChecklistStatusDraft}
/>
</td>
</tr>
);
});
});
<td
colSpan={selectedEmployees.length + 2}
className='py-2 px-4 pl-8'
>
<span className='text-xs font-medium text-gray-600'>
{TIME_TYPE_LABELS[timeType]} (
{activities.length} aktivitas)
</span>
</td>
</tr>
);
}
return rows;
}
)}
</tbody>
</table>
// ACTIVITY rows (Child rows with checkboxes)
activities.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
})
);
activities.forEach((activity, index) => {
const taskId =
taskIdsByPhaseActivityId[activity.id];
const indentClass = hasMultipleTimeTypes
? 'pl-12'
: 'pl-8';
rows.push(
<tr
key={`activity-${activity.id}`}
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>
{sortedSelectedEmployees.map((emp) => (
<td
key={emp.id}
className='text-center py-3 px-4 border-r border-gray-200'
>
<input
type='checkbox'
checked={
assignments[taskId]?.[emp.id]
?.checked || false
}
onChange={(e) =>
handleCheckboxChange(
String(activity.id),
String(emp.id),
e.target.checked
)
}
disabled={!isChecklistStatusDraft}
className='checkbox-clean'
/>
</td>
))}
<td className='py-3 px-4'>
<DebouncedTextArea
delay={500}
name='notes'
rows={1}
placeholder='Catatan (opsional)'
value={
taskId &&
selectedEmployees.length > 0
? assignments[taskId]?.[
selectedEmployees[0].id
]?.note || ''
: ''
}
onChange={(e) => {
if (
selectedEmployees.length > 0
) {
handleNoteChange(
String(activity.id),
String(
selectedEmployees[0].id
),
e.target.value
);
}
}}
disabled={!isChecklistStatusDraft}
/>
</td>
</tr>
);
});
});
return rows;
}
)}
</tbody>
</table>
</div>
) : (
<div className='flex flex-col items-center justify-center py-16 text-center'>
<ListChecks className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Tidak Ada Aktivitas
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Tidak ada aktivitas untuk fase yang dipilih. Silakan
tambahkan aktivitas di Master Aktivitas.
</p>
</div>
)}
</div>
) : (
<div className='flex flex-col items-center justify-center py-16 text-center'>
<ListChecks className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Tidak Ada Aktivitas
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Tidak ada aktivitas untuk fase yang dipilih. Silakan
tambahkan aktivitas di Master Aktivitas.
</p>
{!dailyChecklistId ? (
<div>
<FilePlus className='w-16 h-16 text-gray-300 mb-4 mx-auto' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Mulai Checklist Baru
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Pilih tanggal, kandang, dan kategori untuk memulai
checklist harian Anda.
</p>
</div>
) : selectedPhaseIds.length === 0 ? (
<div className='flex flex-col items-center text-center'>
<FilePlus className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Pilih Fase / Tahap
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Klik tombol {'"'}Pilih Fase{'"'} untuk memilih tahap
aktivitas yang akan dikerjakan.
</p>
</div>
) : (
<div>
<FilePlus className='w-16 h-16 text-gray-300 mb-4 mx-auto' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Pilih ABK
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Klik tombol {'"'}Tambah ABK{'"'} untuk memilih pekerja
yang akan ditugaskan.
</p>
</div>
)}
</div>
)}
</div>
) : (
<div className='flex flex-col items-center justify-center py-16 text-center'>
{!dailyChecklistId ? (
<div>
<FilePlus className='w-16 h-16 text-gray-300 mb-4 mx-auto' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Mulai Checklist Baru
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Pilih tanggal, kandang, dan kategori untuk memulai
checklist harian Anda.
</p>
</div>
) : selectedPhaseIds.length === 0 ? (
<div className='flex flex-col items-center text-center'>
<FilePlus className='w-16 h-16 text-gray-300 mb-4' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Pilih Fase / Tahap
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Klik tombol {'"'}Pilih Fase{'"'} untuk memilih tahap
aktivitas yang akan dikerjakan.
</p>
</div>
) : (
<div>
<FilePlus className='w-16 h-16 text-gray-300 mb-4 mx-auto' />
<h3 className='text-lg font-semibold text-gray-700 mb-2'>
Pilih ABK
</h3>
<p className='text-sm text-gray-500 max-w-md'>
Klik tombol {'"'}Tambah ABK{'"'} untuk memilih pekerja
yang akan ditugaskan.
</p>
</div>
)}
</div>
</>
)}
{dailyChecklistId &&
{!isKandangEmpty &&
dailyChecklistId &&
selectedPhaseIds.length > 0 &&
selectedEmployees.length > 0 && (
<>
@@ -1548,7 +1572,8 @@ export function DailyChecklistContent() {
)}
{/* Action Buttons */}
{dailyChecklistId &&
{!isKandangEmpty &&
dailyChecklistId &&
selectedPhaseIds.length > 0 &&
selectedEmployees.length > 0 &&
isChecklistStatusDraft && (
@@ -2,7 +2,14 @@
import { useState, useEffect } from 'react';
import * as React from 'react';
import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import {
ArrowLeft,
CheckCircle,
XCircle,
AlertCircle,
Share2,
} from 'lucide-react';
import * as htmlToImage from 'html-to-image';
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';
@@ -137,6 +144,8 @@ export function DetailDailyChecklistContent() {
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
useEffect(() => {
if (checklistId) {
fetchChecklistDetail();
@@ -547,6 +556,42 @@ export function DetailDailyChecklistContent() {
});
};
const shareHandler = async () => {
setIsGeneratingImage(true);
const htmlBlob = await htmlToImage.toBlob(document.body);
const imgFile = new File(
[htmlBlob!],
`daily-checklist-${header?.date}-${header?.kandang_name}-${header?.category}.png`,
{
type: 'image/png',
}
);
setIsGeneratingImage(false);
const shareData = {
files: [imgFile],
title: `Daily Checklist - ${formatDate(header?.date || '')} - ${header?.kandang_name} - ${header?.category}`,
text: `Daily Checklist - ${formatDate(header?.date || '')} - ${header?.kandang_name} - ${header?.category}`,
url: window.location.href,
};
try {
if (!navigator.canShare(shareData)) {
toast.error(
'Gagal membagikan checklist, coba dengan perangkat yang berbeda'
);
return;
}
await navigator.share(shareData);
toast.success('Checklist berhasil dibagikan');
} catch (error) {
toast.error('Gagal membagikan checklist');
}
};
if (loading) {
return (
<div className='min-h-screen'>
@@ -584,6 +629,7 @@ export function DetailDailyChecklistContent() {
<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
@@ -592,6 +638,7 @@ export function DetailDailyChecklistContent() {
Lihat detail checklist harian
</p>
</div>
{header.status === 'SUBMITTED' && (
<RequirePermission permissions='lti.daily_checklist.create'>
<div className='flex gap-2'>
@@ -615,6 +662,19 @@ export function DetailDailyChecklistContent() {
</div>
</RequirePermission>
)}
<Button
variant='outline'
size='sm'
onClick={shareHandler}
disabled={isGeneratingImage}
className='border-gray-200'
>
<Share2 className='w-4 h-4 mr-1' />
{!isGeneratingImage && 'Bagikan'}
{isGeneratingImage && 'Memuat...'}
</Button>
</div>
{/* Header Info Card */}
+20 -4
View File
@@ -31,6 +31,8 @@ export type UseTableFilterOptions<TExtra extends Record<string, unknown>> = {
paramMap?: Partial<Record<keyof TableFilterState<TExtra>, string>>;
/** If true, `toSearchParams`/`toQueryString` will omit values equal to defaults */
omitDefaultsInUrl?: boolean;
/** Optional list of state keys that should never be serialized into the URL/query string */
excludeKeysFromUrl?: Partial<(keyof TableFilterState<TExtra>)[]>;
persist?: boolean;
storeName?: string;
@@ -218,9 +220,12 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
);
const extras = useMemo(() => {
const { page, pageSize, ...rest } = state as TableFilterState<
Record<string, unknown>
>;
const stateWithExtras = state as TableFilterState<Record<string, unknown>>;
const rest = Object.fromEntries(
Object.entries(stateWithExtras).filter(
([key]) => key !== 'page' && key !== 'pageSize'
)
);
return rest as TExtra;
}, [state]);
@@ -240,8 +245,13 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
const baseline = options?.omitDefaultsInUrl
? (defaults as Record<string, unknown>)
: null;
const excludedKeys = new Set<string>(
(options?.excludeKeysFromUrl as string[] | undefined) ?? []
);
for (const key of Object.keys(source)) {
if (excludedKeys.has(key)) continue;
const value = source[key];
if (value === undefined || value === null) continue;
@@ -260,7 +270,13 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
if (serialized !== null) params.set(mapped, serialized);
}
return params;
}, [state, defaults, options?.omitDefaultsInUrl, mapKey]);
}, [
state,
defaults,
options?.omitDefaultsInUrl,
options?.excludeKeysFromUrl,
mapKey,
]);
/** Build query string (prefixed with '?', or empty string if none) */
const toQueryString = useCallback(() => {
+1 -1
View File
@@ -5,7 +5,7 @@ import { RequestOptions } from '@/services/http/base';
import { redirectToSSO } from '@/lib/auth-helper';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 });
const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 30_000 });
axiosClient.interceptors.response.use(
(response) => response,
+6
View File
@@ -148,10 +148,16 @@ export type UpdatePurchaseRequestPayload = CreatePurchaseRequestPayload;
export type PurchaseFilter = {
poDate: string;
category: string[];
category_labels?: { label: string; value: number }[];
status: string[];
supplier_id?: number;
supplier_label?: string;
area_id?: number;
area_label?: string;
location_id?: number;
location_label?: string;
project_flock_id?: number;
project_flock_label?: string;
project_flock_kandang_id?: number;
project_flock_kandang_label?: string;
};