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_CREATE,
Permission::ATTENDANCES_DELETE, Permission::ATTENDANCES_DELETE,
Permission::STOK_OPNAMES_SUBMIT,
Permission::ROLES_VIEW, Permission::ROLES_VIEW,
Permission::ROLES_CREATE, Permission::ROLES_CREATE,
Permission::ROLES_UPDATE, Permission::ROLES_UPDATE,
@ -137,7 +139,6 @@ public function permissions(): array
Permission::STOCKS_VIEW, Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_VERIFY,
Permission::CUTTINGS_VERIFY, Permission::CUTTINGS_VERIFY,

View File

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

View File

@ -2,6 +2,7 @@
namespace App\Services\Manage; namespace App\Services\Manage;
use App\Enums\Permission;
use App\Enums\StokOpnameStatus; use App\Enums\StokOpnameStatus;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
@ -20,13 +21,16 @@ public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
) {} ) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{ {
$query = StokOpname::query() $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 { ->withCount(['items', 'items as items_with_difference_count' => function (Builder $query): void {
$query->where('difference', '!=', 0); $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 { ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search']; $search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void { $query->where(function (Builder $query) use ($search): void {

View File

@ -30,7 +30,7 @@ const { open, processing, destroy } = useDestroy({
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<span class="inline-flex"> <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)"> :class="buttonClass" :disabled="disabled" @click="!disabled && (open = true)">
<Trash2 class="size-4" /> <Trash2 class="size-4" />
<span class="sr-only">{{ tooltip || 'Hapus' }}</span> <span class="sr-only">{{ tooltip || 'Hapus' }}</span>

View File

@ -40,7 +40,7 @@ function handleClick(event: MouseEvent) {
variant="ghost" variant="ghost"
size="icon" size="icon"
as-child as-child
class="size-8" class="size-8 edit-btn"
:class="cn(buttonClass, disabled && 'opacity-50')" :class="cn(buttonClass, disabled && 'opacity-50')"
> >
<Link <Link
@ -58,7 +58,7 @@ function handleClick(event: MouseEvent) {
v-else v-else
variant="ghost" variant="ghost"
size="icon" size="icon"
class="size-8" class="size-8 edit-btn"
:class="buttonClass" :class="buttonClass"
:disabled="disabled" :disabled="disabled"
@click="handleClick" @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"> <script setup lang="ts">
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { import {
@ -9,12 +8,11 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname'; import type { PaginatedStokOpnames, StokOpnameListItem } from '@/types/stok-opname';
import { Head, router } from '@inertiajs/vue3'; import { Head, router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { createColumns } from './table/columns'; import { index, create, edit, submit, verify, reject } from '@/routes/admin/manage/stok-opnames';
import { index, create, edit, submit, verify, reject, destroy } from '@/routes/admin/manage/stok-opnames'; import StokOpnameGroupedTable from './table/StokOpnameGroupedTable.vue';
const props = defineProps<{ const props = defineProps<{
stokOpnames: PaginatedStokOpnames; stokOpnames: PaginatedStokOpnames;
@ -42,22 +40,12 @@ const { query, setSearch, setSort, resetFilters, syncFromServer } =
useDataTableQuerySync(() => props.filters, syncFromServer); useDataTableQuerySync(() => props.filters, syncFromServer);
const currentSort = computed<DataTableSort | null>(() => { const pagination = () => ({
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.stokOpnames.current_page, currentPage: props.stokOpnames.current_page,
perPage: props.stokOpnames.per_page, perPage: props.stokOpnames.per_page,
lastPage: props.stokOpnames.last_page, lastPage: props.stokOpnames.last_page,
total: props.stokOpnames.total, total: props.stokOpnames.total,
})); });
function openEdit(item: StokOpnameListItem) { function openEdit(item: StokOpnameListItem) {
router.get(edit.url(item.id)); router.get(edit.url(item.id));
@ -99,10 +87,6 @@ function confirmReject() {
rejectDialogOpen.value = false; rejectDialogOpen.value = false;
} }
const columns = computed(() =>
createColumns(openEdit, openSubmitDialog, openVerifyDialog, openRejectDialog)
);
watch(search, (value) => { watch(search, (value) => {
setSearch(value); setSearch(value);
}); });
@ -132,14 +116,16 @@ watch(
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0"> <CardContent class="min-w-0">
<DataTable <StokOpnameGroupedTable
v-model:search="search" v-model:search="search"
:columns="columns" :stok-opnames="stokOpnames.data"
:data="stokOpnames.data" :first-item="stokOpnames.from"
:pagination="pagination" :pagination="pagination()"
:pagination-links="stokOpnames.links" :pagination-links="stokOpnames.links"
:sort="currentSort" @edit="openEdit"
@sort-change="setSort" @submit="openSubmitDialog"
@verify="openVerifyDialog"
@reject="openRejectDialog"
@filters-reset="resetFilters" @filters-reset="resetFilters"
/> />
</CardContent> </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"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction, RowSubmitAction, RowApproveAction, RowRejectAction } from '@/components/button';
import { Button } from '@/components/ui/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import type { StokOpnameListItem } from '@/types/stok-opname'; import type { StokOpnameListItem } from '@/types/stok-opname';
import { Send, CheckCircle, XCircle } from '@lucide/vue';
import { destroy } from '@/routes/admin/manage/stok-opnames'; import { destroy } from '@/routes/admin/manage/stok-opnames';
defineProps<{ defineProps<{
@ -22,38 +20,23 @@ const { can } = useCan();
<template> <template>
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<Button <RowSubmitAction
v-if="can('stok_opnames.submit') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')" v-if="can('stok_opnames.submit') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"
variant="ghost" tooltip="Ajukan Verifikasi"
size="icon"
class="size-8 text-blue-600"
title="Ajukan Verifikasi"
@click="emit('submit')" @click="emit('submit')"
> />
<Send class="size-4" />
</Button>
<Button <RowApproveAction
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'" v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost" tooltip="Verifikasi"
size="icon"
class="size-8 text-green-600"
title="Verifikasi"
@click="emit('verify')" @click="emit('verify')"
> />
<CheckCircle class="size-4" />
</Button>
<Button <RowRejectAction
v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'" v-if="can('stok_opnames.verify') && stokOpname.status === 'pending'"
variant="ghost" tooltip="Tolak"
size="icon"
class="size-8 text-red-600"
title="Tolak"
@click="emit('reject')" @click="emit('reject')"
> />
<XCircle class="size-4" />
</Button>
<RowEditAction <RowEditAction
v-if="can('stok_opnames.update') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')" v-if="can('stok_opnames.update') && (stokOpname.status === 'draft' || stokOpname.status === 'rejected')"

View File

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

View File

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