feat: enhance stok opname management with new submit action and grouped table view

This commit is contained in:
Yoga Pangestu 2026-06-29 11:04:50 +07:00
parent 2c58c3da5e
commit 950c3e2deb
12 changed files with 228 additions and 131 deletions

View File

@ -44,6 +44,8 @@ public function permissions(): array
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::STOK_OPNAMES_SUBMIT,
Permission::ROLES_VIEW,
Permission::ROLES_CREATE,
Permission::ROLES_UPDATE,
@ -137,7 +139,6 @@ public function permissions(): array
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_VERIFY,
Permission::CUTTINGS_VERIFY,

View File

@ -31,7 +31,7 @@ public function index(Request $request): Response
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/manage/stok-opnames/Index', [
'stokOpnames' => $this->stokOpnameService->paginateForIndex($tableQuery),
'stokOpnames' => $this->stokOpnameService->paginateForIndex($tableQuery, $request->user()),
'filters' => $this->dataTableFilters($tableQuery),
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Services\Manage;
use App\Enums\Permission;
use App\Enums\StokOpnameStatus;
use App\Models\Product;
use App\Models\ProductVariant;
@ -20,13 +21,16 @@ public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{
$query = StokOpname::query()
->with(['createdBy.profile', 'verifiedBy.profile'])
->with(['createdBy.profile', 'verifiedBy.profile', 'items.productVariant.product'])
->withCount(['items', 'items as items_with_difference_count' => function (Builder $query): void {
$query->where('difference', '!=', 0);
}])
->when(! $user->can(Permission::STOK_OPNAMES_SUBMIT), function (Builder $query): void {
$query->where('status', '!=', StokOpnameStatus::DRAFT);
})
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {

View File

@ -30,7 +30,7 @@ const { open, processing, destroy } = useDestroy({
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8 delete-btn"
:class="buttonClass" :disabled="disabled" @click="!disabled && (open = true)">
<Trash2 class="size-4" />
<span class="sr-only">{{ tooltip || 'Hapus' }}</span>

View File

@ -40,7 +40,7 @@ function handleClick(event: MouseEvent) {
variant="ghost"
size="icon"
as-child
class="size-8"
class="size-8 edit-btn"
:class="cn(buttonClass, disabled && 'opacity-50')"
>
<Link
@ -58,7 +58,7 @@ function handleClick(event: MouseEvent) {
v-else
variant="ghost"
size="icon"
class="size-8"
class="size-8 edit-btn"
:class="buttonClass"
:disabled="disabled"
@click="handleClick"

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { Send } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
defineProps<{
tooltip?: string;
disabled?: boolean;
}>();
defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8 text-blue-600 hover:text-blue-700" :disabled="disabled"
@click="$emit('click')">
<Send class="size-4" />
<span class="sr-only">{{ tooltip || 'Ajukan Verifikasi' }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ tooltip || 'Ajukan Verifikasi' }}</TooltipContent>
</Tooltip>
</template>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import CreateButton from '@/components/button/CreateButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import {
@ -9,12 +8,11 @@ import {
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { createColumns } from './table/columns';
import { index, create, edit, submit, verify, reject, destroy } from '@/routes/admin/manage/stok-opnames';
import { ref, watch } from 'vue';
import { index, create, edit, submit, verify, reject } from '@/routes/admin/manage/stok-opnames';
import StokOpnameGroupedTable from './table/StokOpnameGroupedTable.vue';
const props = defineProps<{
stokOpnames: PaginatedStokOpnames;
@ -42,22 +40,12 @@ const { query, setSearch, setSort, resetFilters, syncFromServer } =
useDataTableQuerySync(() => props.filters, syncFromServer);
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
const pagination = () => ({
currentPage: props.stokOpnames.current_page,
perPage: props.stokOpnames.per_page,
lastPage: props.stokOpnames.last_page,
total: props.stokOpnames.total,
}));
});
function openEdit(item: StokOpnameListItem) {
router.get(edit.url(item.id));
@ -99,10 +87,6 @@ function confirmReject() {
rejectDialogOpen.value = false;
}
const columns = computed(() =>
createColumns(openEdit, openSubmitDialog, openVerifyDialog, openRejectDialog)
);
watch(search, (value) => {
setSearch(value);
});
@ -132,14 +116,16 @@ watch(
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
<StokOpnameGroupedTable
v-model:search="search"
:columns="columns"
:data="stokOpnames.data"
:pagination="pagination"
:stok-opnames="stokOpnames.data"
:first-item="stokOpnames.from"
:pagination="pagination()"
:pagination-links="stokOpnames.links"
:sort="currentSort"
@sort-change="setSort"
@edit="openEdit"
@submit="openSubmitDialog"
@verify="openVerifyDialog"
@reject="openRejectDialog"
@filters-reset="resetFilters"
/>
</CardContent>

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed } from 'vue';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useCan } from '@/composables/useCan';
import { usePaginationSummary } from '@/composables/usePaginationSummary';
import { cn } from '@/lib/utils';
import { groupedTableRowNumber } from '@/lib/grouped-table';
import {
stokOpnameDifference,
stokOpnameDifferenceClass,
stokOpnameDifferenceText,
} from '@/lib/stok-opname-display';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { StokOpnameListItem } from '@/types/stok-opname';
import DataTableActions from './data-table-actions.vue';
const props = defineProps<{
stokOpnames: StokOpnameListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
edit: [item: StokOpnameListItem];
submit: [item: StokOpnameListItem];
verify: [item: StokOpnameListItem];
reject: [item: StokOpnameListItem];
'filters-reset': [];
}>();
const { can } = useCan();
const showingCount = computed(() => props.stokOpnames.length);
const paginationSummary = usePaginationSummary(() => props.pagination, showingCount, 'stok opname');
function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar v-model:search="search" search-placeholder="Cari stok opname..."
@filters-reset="emit('filters-reset')" />
<div v-if="stokOpnames.length" class="space-y-4">
<div v-for="(stokOpname, index) in stokOpnames" :key="stokOpname.id" class="overflow-hidden rounded-md border">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ stokOpname.opname_date_formatted }}
</h3>
<Badge
:variant="stokOpnameStatusBadgeVariant(stokOpname.status)"
:class="cn(stokOpname.status === 'verified' && 'bg-green-600 text-white')"
>
{{ stokOpname.status_label }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>Oleh <strong class="text-foreground">{{ stokOpname.created_by_name }}</strong></span>
<span>{{ stokOpname.created_at_formatted }}</span>
</div>
<p v-if="stokOpname.notes" class="text-sm text-muted-foreground">
{{ stokOpname.notes }}
</p>
<p v-if="stokOpname.verification_notes" class="text-sm text-amber-600">
Catatan verifikasi: {{ stokOpname.verification_notes }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<DataTableActions
:stok-opname="stokOpname"
@edit="emit('edit', stokOpname)"
@submit="emit('submit', stokOpname)"
@verify="emit('verify', stokOpname)"
@reject="emit('reject', stokOpname)"
/>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-12 text-center">No.</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead class="text-right">Stok Sistem</TableHead>
<TableHead class="text-right">Stok Fisik</TableHead>
<TableHead class="text-right">Selisih</TableHead>
<TableHead>Catatan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!stokOpname.items?.length" :key="`${stokOpname.id}-empty`">
<TableCell :colspan="7" class="text-muted-foreground text-center">
Belum ada item
</TableCell>
</TableRow>
<TableRow v-for="(item, rIdx) in stokOpname.items" :key="item.id">
<TableCell class="text-center text-muted-foreground tabular-nums">
{{ rIdx + 1 }}
</TableCell>
<TableCell class="font-medium">
{{ item.product_name }}
</TableCell>
<TableCell>
{{ item.variant_name }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.system_stock }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.physical_stock }}
</TableCell>
<TableCell class="text-right tabular-nums">
<span :class="stokOpnameDifferenceClass(stokOpnameDifference(item.physical_stock, item.system_stock))">
{{ stokOpnameDifferenceText(stokOpnameDifference(item.physical_stock, item.system_stock)) }}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
{{ item.notes || '-' }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<DataTableEmpty v-else />
<GroupedTableFooter
:summary="paginationSummary"
:pagination="pagination"
:pagination-links="paginationLinks"
/>
</div>
</template>

View File

@ -1,70 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/data-table';
import { stokOpnameStatusBadgeVariant } from '@/constants/stok-opname-status';
import type { StokOpnameListItem } from '@/types/stok-opname';
import DataTableActions from './data-table-actions.vue';
import { cn } from '@/lib/utils';
export function createColumns(
onEdit: (item: StokOpnameListItem) => void,
onSubmit: (item: StokOpnameListItem) => void,
onVerify: (item: StokOpnameListItem) => void,
onReject: (item: StokOpnameListItem) => void,
): ColumnDef<StokOpnameListItem>[] {
return [
{
accessorKey: 'opname_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'opname_date' }),
},
{
accessorKey: 'status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(Badge, {
variant: stokOpnameStatusBadgeVariant(row.original.status),
class: cn(row.original.status === 'verified' && 'bg-green-600 text-white'),
}, () => row.original.status_label),
},
{
accessorKey: 'items_count',
enableSorting: false,
header: () => 'Jumlah Item',
cell: ({ row }) => `${row.original.items_count} item`,
},
{
accessorKey: 'items_with_difference_count',
enableSorting: false,
header: () => 'Selisih',
cell: ({ row }) => {
const count = row.original.items_with_difference_count;
if (count === 0) return '-';
return h(Badge, { variant: 'destructive' }, () => `${count} selisih`);
},
},
{
accessorKey: 'created_by_name',
enableSorting: false,
header: () => 'Dibuat Oleh',
},
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Waktu', column: 'created_at' }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
stokOpname: row.original,
onEdit: () => onEdit(row.original),
onSubmit: () => onSubmit(row.original),
onVerify: () => onVerify(row.original),
onReject: () => onReject(row.original),
}),
},
];
}

View File

@ -1,9 +1,7 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import { Button } from '@/components/ui/button';
import { RowDeleteAction, RowEditAction, RowSubmitAction, RowApproveAction, RowRejectAction } from '@/components/button';
import { useCan } from '@/composables/useCan';
import type { StokOpnameListItem } from '@/types/stok-opname';
import { Send, CheckCircle, XCircle } from '@lucide/vue';
import { destroy } from '@/routes/admin/manage/stok-opnames';
defineProps<{
@ -22,38 +20,23 @@ const { can } = useCan();
<template>
<div class="flex items-center justify-end gap-1">
<Button
<RowSubmitAction
v-if="can('stok_opnames.submit') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"
variant="ghost"
size="icon"
class="size-8 text-blue-600"
title="Ajukan Verifikasi"
tooltip="Ajukan Verifikasi"
@click="emit('submit')"
>
<Send class="size-4" />
</Button>
/>
<Button
<RowApproveAction
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost"
size="icon"
class="size-8 text-green-600"
title="Verifikasi"
tooltip="Verifikasi"
@click="emit('verify')"
>
<CheckCircle class="size-4" />
</Button>
/>
<Button
<RowRejectAction
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost"
size="icon"
class="size-8 text-red-600"
title="Tolak"
tooltip="Tolak"
@click="emit('reject')"
>
<XCircle class="size-4" />
</Button>
/>
<RowEditAction
v-if="can('stok_opnames.update') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"

View File

@ -94,6 +94,7 @@ watch(
<CreateButton
v-if="can('categories.create')"
@click="openCreateModal"
id="create-category-btn"
/>
</div>

View File

@ -14,6 +14,7 @@ export type StokOpnameListItem = {
created_at_formatted: string;
items_count: number;
items_with_difference_count: number;
items: StokOpnameItem[];
};
export type StokOpnameItem = {