feat: enhance stok opname management with new submit action and grouped table view
This commit is contained in:
parent
2c58c3da5e
commit
950c3e2deb
@ -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,
|
||||
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
27
resources/js/components/button/RowSubmitAction.vue
Normal file
27
resources/js/components/button/RowSubmitAction.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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')"
|
||||
|
||||
@ -94,6 +94,7 @@ watch(
|
||||
<CreateButton
|
||||
v-if="can('categories.create')"
|
||||
@click="openCreateModal"
|
||||
id="create-category-btn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export type StokOpnameListItem = {
|
||||
created_at_formatted: string;
|
||||
items_count: number;
|
||||
items_with_difference_count: number;
|
||||
items: StokOpnameItem[];
|
||||
};
|
||||
|
||||
export type StokOpnameItem = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user