refactor: restrict marketing role permissions and add employee advances and payroll access

This commit is contained in:
Yoga Pangestu 2026-06-14 16:43:46 +07:00
parent 2dab1efeae
commit 69d2212d64
11 changed files with 111 additions and 32 deletions

View File

@ -201,8 +201,6 @@ public function permissions(): array
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
@ -212,28 +210,20 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
self::NON_OPERATOR => [

View File

@ -41,6 +41,7 @@ public function create(Request $request): Response
return Inertia::render('admin/manage/orders/Create', [
'customers' => $this->orderService->customerOptions(),
'marketings' => $this->orderService->marketingOptions(),
'catalog' => $this->orderService->catalogItems(user: $user),
'channels' => OrderChannel::selectOptions(),
'storePriceTypes' => $this->orderService->storePriceTypeOptions(),
@ -68,6 +69,7 @@ public function edit(Order $order): Response|RedirectResponse
return Inertia::render('admin/manage/orders/Edit', [
'order' => $this->orderService->findForEdit($order),
'customers' => $this->orderService->customerOptions(),
'marketings' => $this->orderService->marketingOptions(),
'catalog' => $this->orderService->catalogItems($order),
'channels' => OrderChannel::selectOptions(),
'storePriceTypes' => $this->orderService->storePriceTypeOptions(),

View File

@ -28,6 +28,7 @@ public function rules(): array
{
$rules = [
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')->whereNull('deleted_at')],
'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')->whereNull('deleted_at')],
'channel' => ['required', Rule::enum(OrderChannel::class)],
'price_type' => ['required', Rule::enum(PriceType::class)],
'discount' => ['nullable', 'integer', 'min:0'],
@ -54,6 +55,7 @@ public function attributes(): array
{
return [
'customer_id' => 'pelanggan',
'marketing_id' => 'marketing',
'channel' => 'channel',
'price_type' => 'tipe harga',
'discount' => 'diskon',

View File

@ -54,6 +54,11 @@ public function createdBy(): BelongsTo
return $this->belongsTo(User::class, 'created_by_id');
}
public function marketing(): BelongsTo
{
return $this->belongsTo(User::class, 'marketing_id');
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);

View File

@ -41,6 +41,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
'items.productVariant.product:id,name',
'items.productVariant:id,product_id,name',
])
->when($user->hasRole('marketing'), fn (Builder $query) => $query->where('marketing_id', $user->id))
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
@ -87,6 +88,24 @@ public function customerOptions(): array
->all();
}
/**
* @return list<array{value: int, label: string}>
*/
public function marketingOptions(): array
{
return User::query()
->active()
->whereHas('roles', fn ($q) => $q->where('name', 'marketing'))
->with('profile:user_id,full_name')
->orderBy('username')
->get(['id', 'username'])
->map(fn (User $user) => [
'value' => $user->id,
'label' => $user->profile?->full_name ?? $user->username,
])
->all();
}
/**
* @return list<array{value: string, label: string}>
*/
@ -301,6 +320,7 @@ public function create(array $validated, User $user): Order
$order = Order::create([
'customer_id' => $validated['customer_id'] ?? null,
'marketing_id' => $validated['marketing_id'] ?? null,
'channel' => $channel,
'price_type' => $priceType,
'status' => OrderStatus::PENDING,
@ -374,6 +394,7 @@ public function update(Order $order, array $validated): void
$channel = OrderChannel::from($validated['channel']);
$order->customer_id = $validated['customer_id'] ?? null;
$order->marketing_id = $validated['marketing_id'] ?? null;
$order->channel = $channel;
$order->price_type = $priceType;
$order->subtotal = $subtotal;

View File

@ -15,6 +15,7 @@ public function up(): void
$table->id();
$table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('marketing_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('order_number', 30)->unique();
$table->enum('channel', array_column(OrderChannel::cases(), 'value'));

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import type { Auth } from '@/types/auth';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import { NumberInput } from '@/components/form/number-input';
@ -42,11 +43,13 @@ import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
const props = defineProps<{
customers: SelectOption[];
marketings: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
initialData?: {
customer_id: string;
marketing_id: string;
channel: string;
price_type: string;
discount: string;
@ -61,11 +64,23 @@ const props = defineProps<{
const isCreateMode = computed(() => props.method === 'post');
const page = usePage();
const authUser = computed(() => (page.props.auth as Auth).user);
const isMarketingUser = computed(() =>
authUser.value?.roles?.includes('marketing') ?? false,
);
const search = ref('');
const cart = ref<OrderCartItem[]>([]);
// Auto-fill marketing_id with logged-in user's id if they are marketing role
const defaultMarketingId = computed(() =>
isMarketingUser.value && authUser.value ? String(authUser.value.id) : '',
);
const form = useForm({
customer_id: '',
marketing_id: defaultMarketingId.value || 'none',
channel: 'store',
price_type: 'ecer',
discount: '',
@ -80,6 +95,7 @@ function populateForm() {
}
form.customer_id = props.initialData.customer_id;
form.marketing_id = props.initialData.marketing_id || 'none';
form.channel = props.initialData.channel;
form.price_type = props.initialData.price_type;
form.discount = props.initialData.discount;
@ -323,6 +339,12 @@ function buildFormData(): FormData {
formData.append('customer_id', form.customer_id);
}
if (form.marketing_id && form.marketing_id !== 'none') {
formData.append('marketing_id', form.marketing_id);
} else if (isMarketingUser.value && authUser.value) {
formData.append('marketing_id', String(authUser.value.id));
}
formData.append('channel', form.channel);
formData.append('price_type', form.price_type);
formData.append('discount', parseRupiah(form.discount));
@ -502,6 +524,30 @@ function submit() {
<FieldError :errors="formErrors(form, 'customer_id')" />
</Field>
<Field>
<FieldLabel for="marketing">Marketing</FieldLabel>
<Select v-model="form.marketing_id" :disabled="isMarketingUser">
<SelectTrigger id="marketing" class="w-full">
<SelectValue placeholder="Pilih marketing (opsional)" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
Tidak ada
</SelectItem>
<SelectItem v-for="marketing in marketings" :key="marketing.value"
:value="String(marketing.value)">
{{ marketing.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p v-if="isMarketingUser" class="text-xs text-muted-foreground">
Otomatis tercatat atas nama Anda.
</p>
<FieldError :errors="formErrors(form, 'marketing_id')" />
</Field>
<div v-if="cart.length === 0"
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>

View File

@ -8,6 +8,7 @@ import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '
defineProps<{
customers: SelectOption[];
marketings: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
@ -36,6 +37,7 @@ defineProps<{
<OrderPosForm
:customers="customers"
:marketings="marketings"
:catalog="catalog"
:channels="channels"
:store-price-types="storePriceTypes"

View File

@ -10,6 +10,7 @@ import type { EnumOption, OrderCatalogItem, OrderEditItem, SelectOption } from '
const props = defineProps<{
order: OrderEditItem;
customers: SelectOption[];
marketings: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
@ -17,6 +18,7 @@ const props = defineProps<{
const initialData = computed(() => ({
customer_id: props.order.customer_id ? String(props.order.customer_id) : '',
marketing_id: props.order.marketing_id ? String(props.order.marketing_id) : '',
channel: props.order.channel,
price_type: props.order.price_type,
discount: String(props.order.discount),
@ -56,6 +58,7 @@ const initialData = computed(() => ({
<OrderPosForm
:customers="customers"
:marketings="marketings"
:catalog="catalog"
:channels="channels"
:store-price-types="storePriceTypes"

View File

@ -1,15 +1,18 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import OrderGroupedTable from '@/components/admin/manage/orders/OrderGroupedTable.vue';
import ThermalPrinterConnectButton from '@/components/admin/manage/orders/ThermalPrinterConnectButton.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import {
useDataTableQuery,
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PaginatedOrders } from '@/types/order';
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
const props = defineProps<{
orders: PaginatedOrders;
@ -37,11 +40,11 @@ const tablePagination = computed(() => ({
total: props.orders.total,
}));
const firstItem = computed(() => (
const firstItem = computed(() =>
props.orders.data.length > 0
? (props.orders.current_page - 1) * props.orders.per_page + 1
: 0
));
: 0,
);
watch(search, (value) => {
setSearch(value);
@ -59,17 +62,20 @@ watch(
<Head title="Pesanan" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Pesanan
</h2>
<h2 class="text-2xl font-bold tracking-tight">Pesanan</h2>
</div>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<div
class="flex shrink-0 items-center gap-2 self-start sm:self-center"
v-if="can('orders.create')"
>
<ThermalPrinterConnectButton />
<Button v-if="can('orders.create')" as-child>
<Button as-child>
<Link href="/admin/manage/orders/create">
<Plus class="size-4" />
Tambah

View File

@ -76,6 +76,7 @@ export type OrderEditItem = {
id: number;
order_number: string;
customer_id: number | null;
marketing_id: number | null;
channel: string;
price_type: string;
status: string;