Refactor code for improved readability and consistency across multiple files

- Adjusted indentation and formatting in login, permissions, profile, and security pages for better readability.
- Enhanced the clarity of conditional statements and function calls in permissions and profile components.
- Updated type definitions in vite-env.d.ts for better code structure.
- Cleaned up array mapping syntax in ProductTest.php for consistency.
This commit is contained in:
Yoga Pangestu 2026-08-01 10:14:47 +07:00
parent 2a3e70b78d
commit 23cc327190
74 changed files with 5508 additions and 2415 deletions

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\EmployeeAdvance;
use App\Services\Admin\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\ExpenseRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Expense;
use App\Services\Admin\Finance\ExpenseService;
use Illuminate\Http\RedirectResponse;

View File

@ -16,7 +16,7 @@ public function __construct(
public function pay(Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->pay($payroll),
fn () => $this->service->pay($payroll),
'Gaji berhasil dibayar.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse
public function cancel(Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->cancel($payroll),
fn () => $this->service->cancel($payroll),
'Gaji berhasil dibatalkan.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\HR;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\HR\EmployeeRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\User;
use App\Services\Admin\HR\EmployeeService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\HR;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\HR\LeaveRequestRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\LeaveRequest;
use App\Services\Admin\HR\LeaveRequestService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\CategoryRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Category;
use App\Services\Admin\Master\CategoryService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\CustomerRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Customer;
use App\Services\Admin\Master\CustomerService;
use Illuminate\Http\RedirectResponse;

View File

@ -2,10 +2,9 @@
namespace App\Http\Controllers\Admin\Master;
use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\ProductRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Product;
use App\Services\Admin\Master\CategoryService;
use App\Services\Admin\Master\ProductService;
@ -41,7 +40,7 @@ public function create(): Response
public function store(ProductRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->create($request->validated()),
fn () => $this->service->create($request->validated()),
'Produk berhasil ditambahkan.',
'admin.master.products.index',
'admin.master.products.create'
@ -59,7 +58,7 @@ public function edit(Product $product): Response
public function update(ProductRequest $request, Product $product): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($product, $request->validated()),
fn () => $this->service->update($product, $request->validated()),
'Produk berhasil diperbarui.',
'admin.master.products.index',
'admin.master.products.edit',
@ -70,7 +69,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
public function destroy(Product $product): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->delete($product),
fn () => $this->service->delete($product),
'Produk berhasil dihapus.',
'admin.master.products.index'
);

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\SupplierRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Supplier;
use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\RoleRequest;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\Settings\RoleService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

View File

@ -13,13 +13,16 @@ protected function handleAction(callable $action, string $successMessage, string
try {
$action();
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
return to_route($redirectRoute, $parameters);
} catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
return to_route($errorRoute ?? $redirectRoute, $parameters);
} catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
return to_route($errorRoute ?? $redirectRoute, $parameters);
}
}

View File

@ -65,7 +65,7 @@ public function rules(): array
'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_key' => ['required', 'string', 'max:500'],
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(!$useSamePrice ? ['size:9'] : [])],
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'],

View File

@ -11,7 +11,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['type_label'])]

View File

@ -14,7 +14,7 @@
#[Guarded(['id'])]
class ProductVariant extends Model implements HasMedia
{
use HasFactory, SoftDeletes, InteractsWithMedia;
use HasFactory, InteractsWithMedia, SoftDeletes;
public function orderItems(): HasMany
{

View File

@ -2,10 +2,9 @@
namespace App\Services\Admin\Master;
use App\Enums\PriceType;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@ -123,6 +122,7 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos')->first();
return [
'id' => $variant->id,
'name' => $variant->name,

View File

@ -1,9 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/inter';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@ -65,137 +65,74 @@ @theme {
}
:root {
--background:
oklch(1 0 0);
--foreground:
oklch(0.145 0 0);
--card:
oklch(1 0 0);
--card-foreground:
oklch(0.145 0 0);
--popover:
oklch(1 0 0);
--popover-foreground:
oklch(0.145 0 0);
--primary:
oklch(0.555 0.163 48.998);
--primary-foreground:
oklch(0.987 0.022 95.277);
--secondary:
oklch(0.967 0.001 286.375);
--secondary-foreground:
oklch(0.21 0.006 285.885);
--muted:
oklch(0.97 0 0);
--muted-foreground:
oklch(0.556 0 0);
--accent:
oklch(0.97 0 0);
--accent-foreground:
oklch(0.205 0 0);
--destructive:
oklch(0.577 0.245 27.325);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.555 0.163 48.998);
--primary-foreground: oklch(0.987 0.022 95.277);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border:
oklch(0.922 0 0);
--input:
oklch(0.922 0 0);
--ring:
oklch(0.708 0 0);
--chart-1:
oklch(0.879 0.169 91.605);
--chart-2:
oklch(0.769 0.188 70.08);
--chart-3:
oklch(0.666 0.179 58.318);
--chart-4:
oklch(0.555 0.163 48.998);
--chart-5:
oklch(0.473 0.137 46.201);
--radius:
0.625rem;
--sidebar:
oklch(0.985 0 0);
--sidebar-foreground:
oklch(0.145 0 0);
--sidebar-primary:
oklch(0.666 0.179 58.318);
--sidebar-primary-foreground:
oklch(0.987 0.022 95.277);
--sidebar-accent:
oklch(0.97 0 0);
--sidebar-accent-foreground:
oklch(0.205 0 0);
--sidebar-border:
oklch(0.922 0 0);
--sidebar-ring:
oklch(0.708 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.666 0.179 58.318);
--sidebar-primary-foreground: oklch(0.987 0.022 95.277);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background:
oklch(0.145 0 0);
--foreground:
oklch(0.985 0 0);
--card:
oklch(0.205 0 0);
--card-foreground:
oklch(0.985 0 0);
--popover:
oklch(0.205 0 0);
--popover-foreground:
oklch(0.985 0 0);
--primary:
oklch(0.473 0.137 46.201);
--primary-foreground:
oklch(0.987 0.022 95.277);
--secondary:
oklch(0.274 0.006 286.033);
--secondary-foreground:
oklch(0.985 0 0);
--muted:
oklch(0.269 0 0);
--muted-foreground:
oklch(0.708 0 0);
--accent:
oklch(0.269 0 0);
--accent-foreground:
oklch(0.985 0 0);
--destructive:
oklch(0.704 0.191 22.216);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.473 0.137 46.201);
--primary-foreground: oklch(0.987 0.022 95.277);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border:
oklch(1 0 0 / 10%);
--input:
oklch(1 0 0 / 15%);
--ring:
oklch(0.556 0 0);
--chart-1:
oklch(0.879 0.169 91.605);
--chart-2:
oklch(0.769 0.188 70.08);
--chart-3:
oklch(0.666 0.179 58.318);
--chart-4:
oklch(0.555 0.163 48.998);
--chart-5:
oklch(0.473 0.137 46.201);
--sidebar:
oklch(0.205 0 0);
--sidebar-foreground:
oklch(0.985 0 0);
--sidebar-primary:
oklch(0.769 0.188 70.08);
--sidebar-primary-foreground:
oklch(0.279 0.077 45.635);
--sidebar-accent:
oklch(0.269 0 0);
--sidebar-accent-foreground:
oklch(0.985 0 0);
--sidebar-border:
oklch(1 0 0 / 10%);
--sidebar-ring:
oklch(0.556 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.769 0.188 70.08);
--sidebar-primary-foreground: oklch(0.279 0.077 45.635);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@ -206,90 +143,50 @@ @layer base {
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
html {
@apply font-sans;
}
}
@theme inline {
--font-heading:
var(--font-sans);
--font-sans:
'Inter Variable', sans-serif;
--color-sidebar-ring:
var(--sidebar-ring);
--color-sidebar-border:
var(--sidebar-border);
--color-sidebar-accent-foreground:
var(--sidebar-accent-foreground);
--color-sidebar-accent:
var(--sidebar-accent);
--color-sidebar-primary-foreground:
var(--sidebar-primary-foreground);
--color-sidebar-primary:
var(--sidebar-primary);
--color-sidebar-foreground:
var(--sidebar-foreground);
--color-sidebar:
var(--sidebar);
--color-chart-5:
var(--chart-5);
--color-chart-4:
var(--chart-4);
--color-chart-3:
var(--chart-3);
--color-chart-2:
var(--chart-2);
--color-chart-1:
var(--chart-1);
--color-ring:
var(--ring);
--color-input:
var(--input);
--color-border:
var(--border);
--color-destructive:
var(--destructive);
--color-accent-foreground:
var(--accent-foreground);
--color-accent:
var(--accent);
--color-muted-foreground:
var(--muted-foreground);
--color-muted:
var(--muted);
--color-secondary-foreground:
var(--secondary-foreground);
--color-secondary:
var(--secondary);
--color-primary-foreground:
var(--primary-foreground);
--color-primary:
var(--primary);
--color-popover-foreground:
var(--popover-foreground);
--color-popover:
var(--popover);
--color-card-foreground:
var(--card-foreground);
--color-card:
var(--card);
--color-foreground:
var(--foreground);
--color-background:
var(--background);
--radius-sm:
calc(var(--radius) * 0.6);
--radius-md:
calc(var(--radius) * 0.8);
--radius-lg:
var(--radius);
--radius-xl:
calc(var(--radius) * 1.4);
--radius-2xl:
calc(var(--radius) * 1.8);
--radius-3xl:
calc(var(--radius) * 2.2);
--radius-4xl:
calc(var(--radius) * 2.6);
}
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}

View File

@ -2,6 +2,8 @@ import type { ImgHTMLAttributes } from 'react';
import logo from '../../../public/assets/logo.png';
export default function AppLogoIcon(props: ImgHTMLAttributes<HTMLImageElement>) {
export default function AppLogoIcon(
props: ImgHTMLAttributes<HTMLImageElement>,
) {
return <img src={logo} alt="Logo" {...props} />;
}

View File

@ -106,7 +106,10 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={{ children: item.title }}>
<SidebarMenuButton
asChild
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch>
<item.icon />
<span>{item.title}</span>
@ -151,7 +154,11 @@ export function AppSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isCurrentUrl('/dashboard')} tooltip={{ children: dasborItem.title }}>
<SidebarMenuButton
asChild
isActive={isCurrentUrl('/dashboard')}
tooltip={{ children: dasborItem.title }}
>
<Link href={dasborItem.href} prefetch>
<dasborItem.icon />
<span>{dasborItem.title}</span>
@ -164,7 +171,10 @@ export function AppSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={{ children: analisaItem.title }}>
<SidebarMenuButton
asChild
tooltip={{ children: analisaItem.title }}
>
<Link href={analisaItem.href} prefetch>
<analisaItem.icon />
<span>{analisaItem.title}</span>

View File

@ -25,7 +25,9 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
}
setError(null);
} catch {
setError('Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.');
setError(
'Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.',
);
}
}, []);
@ -81,14 +83,24 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
{error ? (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-center text-sm text-muted-foreground">{error}</p>
<p className="text-center text-sm text-muted-foreground">
{error}
</p>
<Button onClick={startCamera}>Coba Lagi</Button>
</div>
) : capturedImage ? (
<div className="flex flex-col gap-4">
<img src={capturedImage} alt="Captured" className="w-full rounded-lg" />
<img
src={capturedImage}
alt="Captured"
className="w-full rounded-lg"
/>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={retake}>
<Button
variant="outline"
className="flex-1"
onClick={retake}
>
<RotateCcw className="mr-2 h-4 w-4" />
Ulangi
</Button>

View File

@ -4,7 +4,7 @@ import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
@ -17,9 +17,15 @@ import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable
useReactTable,
} from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, GripVertical } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
GripVertical,
} from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
@ -167,8 +173,8 @@ export function DataTable<TData, TValue>({
}: DataTableProps<TData, TValue>) {
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) {
return {};
}
return {};
}
const initial: Record<string, boolean> = {};
data.forEach((item, index) => {
@ -194,17 +200,17 @@ return {};
const visibleColumns = isSortable
? [
{
id: 'drag',
header: '',
cell: () => <DragHandleTrigger />,
meta: {
className: 'w-[40px]',
headerClassName: 'w-[40px]',
},
} as ColumnDef<TData, TValue>,
...columns,
]
{
id: 'drag',
header: '',
cell: () => <DragHandleTrigger />,
meta: {
className: 'w-[40px]',
headerClassName: 'w-[40px]',
},
} as ColumnDef<TData, TValue>,
...columns,
]
: columns;
const sensors = useSensors(
@ -261,8 +267,8 @@ return {};
function handleSort(columnId: string) {
if (!onSortChange) {
return;
}
return;
}
const newDirection =
currentSort?.column === columnId && currentSort?.direction === 'asc'
@ -282,25 +288,31 @@ return;
<Input
placeholder={searchPlaceholder}
value={localSearch}
onChange={(event) => handleSearchChange(event.target.value)}
onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm"
/>
)}
{toolbar}
<div className="flex items-center gap-2 ml-auto">
<div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && (
<Select
value={String(pagination?.per_page ?? 25)}
onValueChange={(value) => onPerPageChange(Number(value))}
onValueChange={(value) =>
onPerPageChange(Number(value))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="999999">Semua</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="999999">
Semua
</SelectItem>
</SelectContent>
</Select>
)}
@ -320,18 +332,18 @@ return;
(
header.column.columnDef
.meta as {
headerClassName?: string;
}
headerClassName?: string;
}
)?.headerClassName
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
@ -374,8 +386,8 @@ return;
.column
.columnDef
.meta as {
className?: string;
}
className?: string;
}
)
?.className
}
@ -394,55 +406,58 @@ return;
</SortableContext>
</DndContext>
) : (
table
.getRowModel()
.rows.map((row) => (
<React.Fragment key={row.id}>
<TableRow
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)?.className
}
>
{flexRender(
table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
<TableRow
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderSubRow && row.getIsExpanded() && (
.meta as {
className?: string;
}
)?.className
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
<TableRow>
<TableCell
colSpan={visibleColumns.length}
colSpan={
visibleColumns.length
}
className="bg-muted/50 p-0"
>
<div className="p-4">
{renderSubRow(row, localSearch)}
{renderSubRow(
row,
localSearch,
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))
</React.Fragment>
))
)
) : (
<TableRow>
@ -530,7 +545,9 @@ return;
<Button
variant="outline"
size="sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />

View File

@ -1,33 +1,33 @@
import * as React from "react"
import { format } from "date-fns"
import { id } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import * as React from 'react';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
} from '@/components/ui/popover';
interface DatePickerProps {
value?: Date | string | null
onChange?: (date: Date | undefined) => void
placeholder?: string
disabled?: boolean
className?: string
name?: string
id?: string
min?: Date
max?: Date
value?: Date | string | null;
onChange?: (date: Date | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
name?: string;
id?: string;
min?: Date;
max?: Date;
}
function DatePicker({
value,
onChange,
placeholder = "Pilih tanggal",
placeholder = 'Pilih tanggal',
disabled = false,
className,
name,
@ -35,18 +35,18 @@ function DatePicker({
min,
max,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = React.useState(false);
const date = React.useMemo(() => {
if (!value) return undefined
if (value instanceof Date) return value
return new Date(value)
}, [value])
if (!value) return undefined;
if (value instanceof Date) return value;
return new Date(value);
}, [value]);
const formattedDate = React.useMemo(() => {
if (!date) return ""
return format(date, "dd MMM yyyy", { locale: id })
}, [date])
if (!date) return '';
return format(date, 'dd MMM yyyy', { locale: id });
}, [date]);
return (
<Popover open={open} onOpenChange={setOpen}>
@ -56,9 +56,9 @@ function DatePicker({
variant="outline"
disabled={disabled}
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground",
className
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground',
className,
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
@ -70,22 +70,26 @@ function DatePicker({
mode="single"
selected={date}
onSelect={(selectedDate) => {
onChange?.(selectedDate)
setOpen(false)
onChange?.(selectedDate);
setOpen(false);
}}
disabled={(date) => {
if (min && date < min) return true
if (max && date > max) return true
return false
if (min && date < min) return true;
if (max && date > max) return true;
return false;
}}
initialFocus
/>
</PopoverContent>
{name && (
<input type="hidden" name={name} value={date ? format(date, "yyyy-MM-dd") : ""} />
<input
type="hidden"
name={name}
value={date ? format(date, 'yyyy-MM-dd') : ''}
/>
)}
</Popover>
)
);
}
export { DatePicker }
export { DatePicker };

View File

@ -49,7 +49,12 @@ function acceptToLabels(accept: string): string[] {
return accept
.split(',')
.map((mime) => mimeMap[mime.trim()] || mime.trim().split('/').pop()?.toUpperCase() || 'File')
.map(
(mime) =>
mimeMap[mime.trim()] ||
mime.trim().split('/').pop()?.toUpperCase() ||
'File',
)
.filter((v, i, a) => a.indexOf(v) === i);
}
@ -60,7 +65,16 @@ function formatMaxSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
}
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', maxSize = 10 * 1024 * 1024, onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
export function FileUpload({
value,
onChange,
folder,
accept = 'image/jpeg,image/png,image/webp,image/gif',
maxSize = 10 * 1024 * 1024,
onUploadingChange,
existingUrl,
onFileMeta,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const uploadId = useId();
const [uploading, setUploading] = useState(false);
@ -96,13 +110,15 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
onChange(key);
onFileMeta?.({ size: file.size, type: file.type });
} catch (err) {
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.';
const message =
err instanceof UploadError
? err.message
: 'Gagal mengunggah file.';
setError(message);
setPreview(null);
setFileName(null);
setFileSize(null);
onFileMeta?.(null);
} finally {
setUploading(false);
@ -126,7 +142,13 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
}
}
const state = uploading ? 'uploading' : error ? 'error' : value ? 'done' : 'idle';
const state = uploading
? 'uploading'
: error
? 'error'
: value
? 'done'
: 'idle';
return (
<>
@ -139,13 +161,21 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
id={uploadId}
/>
<Attachment state={state} orientation="horizontal" className='w-full'>
<Attachment
state={state}
orientation="horizontal"
className="w-full"
>
<AttachmentTrigger
onClick={() => inputRef.current?.click()}
aria-label={value ? 'Ganti file' : 'Pilih file untuk diunggah'}
aria-label={
value ? 'Ganti file' : 'Pilih file untuk diunggah'
}
/>
<AttachmentMedia variant={preview || existingUrl ? 'image' : 'icon'}>
<AttachmentMedia
variant={preview || existingUrl ? 'image' : 'icon'}
>
{preview ? (
<img src={preview} alt={fileName ?? 'Preview'} />
) : existingUrl && value ? (
@ -162,18 +192,24 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
<>
<AttachmentTitle>{fileName}</AttachmentTitle>
<AttachmentDescription>
{fileSize ? formatFileSize(fileSize) : 'Terupload'}
{fileSize
? formatFileSize(fileSize)
: 'Terupload'}
</AttachmentDescription>
</>
) : uploading ? (
<>
<AttachmentTitle>Mengunggah...</AttachmentTitle>
<AttachmentDescription>Memproses file</AttachmentDescription>
<AttachmentDescription>
Memproses file
</AttachmentDescription>
</>
) : error ? (
<>
<AttachmentTitle>Gagal</AttachmentTitle>
<AttachmentDescription>{error}</AttachmentDescription>
<AttachmentDescription>
{error}
</AttachmentDescription>
</>
) : (
<>
@ -187,7 +223,10 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
{value && (
<AttachmentActions>
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
<AttachmentAction
aria-label="Hapus file"
onClick={handleRemove}
>
<X />
</AttachmentAction>
</AttachmentActions>

View File

@ -1,4 +1,9 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
type ImagePreviewModalProps = {
open: boolean;
@ -8,7 +13,13 @@ type ImagePreviewModalProps = {
alt?: string;
};
export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Preview' }: ImagePreviewModalProps) {
export function ImagePreviewModal({
open,
onOpenChange,
src,
title,
alt = 'Preview',
}: ImagePreviewModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton>
@ -21,7 +32,7 @@ export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Previ
<img
src={src}
alt={alt}
className="w-full rounded-lg object-contain max-h-[80vh]"
className="max-h-[80vh] w-full rounded-lg object-contain"
/>
)}
</DialogContent>

View File

@ -9,7 +9,12 @@ interface LocationMapProps {
zoom?: number;
}
export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }: LocationMapProps) {
export function LocationMap({
latitude,
longitude,
height = '250px',
zoom = 15,
}: LocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
@ -26,7 +31,8 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const icon = L.divIcon({
@ -46,5 +52,11 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
};
}, [latitude, longitude, zoom]);
return <div ref={mapRef} style={{ height, width: '100%' }} className="rounded-lg" />;
return (
<div
ref={mapRef}
style={{ height, width: '100%' }}
className="rounded-lg"
/>
);
}

View File

@ -19,17 +19,14 @@ export function NavUser() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
data-test="header-user-menu-button"
>
<UserInfo user={auth.user} />
<ChevronsUpDown className="ml-auto size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56 rounded-lg"
align="end"
>
<DropdownMenuContent className="w-56 rounded-lg" align="end">
<UserMenuContent user={auth.user} />
</DropdownMenuContent>
</DropdownMenu>

View File

@ -12,7 +12,8 @@ export function PWAUpdateToast() {
const handleUpdateAvailable = () => {
toast.info('Update tersedia!', {
description: 'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
description:
'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
duration: Infinity,
action: {
label: 'Update',
@ -28,7 +29,10 @@ export function PWAUpdateToast() {
return () => {
window.removeEventListener('sw-offline-ready', handleOfflineReady);
window.removeEventListener('sw-update-available', handleUpdateAvailable);
window.removeEventListener(
'sw-update-available',
handleUpdateAvailable,
);
};
}, []);

View File

@ -40,7 +40,9 @@ export function RupiahInput({
className,
}: RupiahInputProps) {
const isControlled = value !== undefined;
const [displayValue, setDisplayValue] = useState(formatRupiah(isControlled ? value : defaultValue));
const [displayValue, setDisplayValue] = useState(
formatRupiah(isControlled ? value : defaultValue),
);
const lastValidRef = useRef(isControlled ? value : defaultValue);
if (isControlled) {

View File

@ -18,27 +18,54 @@ export function useFileUpload() {
preview: null,
});
const upload = useCallback(async (file: File, folder?: string): Promise<string | null> => {
setState({ uploading: true, progress: 0, error: null, key: null, preview: null });
const upload = useCallback(
async (file: File, folder?: string): Promise<string | null> => {
setState({
uploading: true,
progress: 0,
error: null,
key: null,
preview: null,
});
try {
const preview = URL.createObjectURL(file);
setState((prev) => ({ ...prev, preview, progress: 30 }));
try {
const preview = URL.createObjectURL(file);
setState((prev) => ({ ...prev, preview, progress: 30 }));
const key = await uploadFile(file, folder);
setState((prev) => ({ ...prev, key, uploading: false, progress: 100 }));
const key = await uploadFile(file, folder);
setState((prev) => ({
...prev,
key,
uploading: false,
progress: 100,
}));
return key;
} catch (err) {
const message = err instanceof UploadError ? err.message : 'Terjadi kesalahan saat mengunggah file.';
setState((prev) => ({ ...prev, error: message, uploading: false }));
return key;
} catch (err) {
const message =
err instanceof UploadError
? err.message
: 'Terjadi kesalahan saat mengunggah file.';
setState((prev) => ({
...prev,
error: message,
uploading: false,
}));
return null;
}
}, []);
return null;
}
},
[],
);
const reset = useCallback(() => {
setState({ uploading: false, progress: 0, error: null, key: null, preview: null });
setState({
uploading: false,
progress: 0,
error: null,
key: null,
preview: null,
});
}, []);
const setKey = useCallback((key: string | null) => {

View File

@ -9,8 +9,8 @@ function getInitial(name: string): string {
export function useInitials(): GetInitialsFn {
return useCallback((fullName: string): string => {
if (!fullName) {
return '';
}
return '';
}
const names = fullName.trim().split(/\s+/u).filter(Boolean);

View File

@ -1,20 +1,24 @@
import * as React from "react"
import * as React from 'react';
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
React.useEffect(() => {
const mql = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange)
}, [])
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View File

@ -11,7 +11,10 @@ export default function AppSidebarLayout({
return (
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" className="min-h-svh overflow-x-hidden overflow-y-auto">
<AppContent
variant="sidebar"
className="min-h-svh overflow-x-hidden overflow-y-auto"
>
<AppSidebarHeader breadcrumbs={breadcrumbs} />
{children}
</AppContent>

View File

@ -26,7 +26,11 @@ export default function AuthCardLayout({
className="flex items-center gap-2 self-center font-medium"
>
<div className="flex items-center justify-center">
<img src="/assets/logo.png" alt="Logo" className="size-40" />
<img
src="/assets/logo.png"
alt="Logo"
className="size-40"
/>
</div>
</Link>

View File

@ -73,9 +73,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<Separator className="my-6 lg:hidden" />
<section className="flex-1 space-y-12">
{children}
</section>
<section className="flex-1 space-y-12">{children}</section>
</div>
</div>
);

View File

@ -15,7 +15,12 @@ export class UploadError extends Error {
}
function getSessionCookie(): string {
return document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? '';
return (
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')[1] ?? ''
);
}
export async function requestPresignedUrl(
@ -31,7 +36,11 @@ export async function requestPresignedUrl(
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()),
},
body: JSON.stringify({ file_name: fileName, mime_type: mimeType, folder }),
body: JSON.stringify({
file_name: fileName,
mime_type: mimeType,
folder,
}),
});
if (!response.ok) {
@ -59,14 +68,20 @@ export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
export async function uploadFile(file: File, folder?: string): Promise<string> {
if (!ALLOWED_TYPES.includes(file.type)) {
throw new UploadError('Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.');
throw new UploadError(
'Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.',
);
}
if (file.size > MAX_FILE_SIZE) {
throw new UploadError('Ukuran file melebihi batas 10MB.');
}
const { upload_url, key } = await requestPresignedUrl(file.name, file.type, folder);
const { upload_url, key } = await requestPresignedUrl(
file.name,
file.type,
folder,
);
await uploadToS3(upload_url, file);
return key;

View File

@ -30,9 +30,7 @@ export function createCashAccountColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createCashAccountColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama</span>
@ -68,9 +64,7 @@ export function createCashAccountColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Saldo</span>
@ -101,16 +95,12 @@ export function createCashAccountColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(cashAccount)
}
onClick={() => handleEdit(cashAccount)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -1,5 +1,11 @@
import { Form, Head, router } from '@inertiajs/react';
import { ArrowDownToLine, ArrowUpFromLine, Filter, Wallet, X } from 'lucide-react';
import {
ArrowDownToLine,
ArrowUpFromLine,
Filter,
Wallet,
X,
} from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
@ -18,11 +24,28 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formatCurrency } from '@/lib/utils';
import { index as cashAccountIndex, deposit, withdrawal } from '@/routes/admin/finance/cash-accounts';
import { update as updateTransaction, destroy as destroyTransaction } from '@/routes/admin/finance/cash-accounts/transactions';
import {
index as cashAccountIndex,
deposit,
withdrawal,
} from '@/routes/admin/finance/cash-accounts';
import {
update as updateTransaction,
destroy as destroyTransaction,
} from '@/routes/admin/finance/cash-accounts/transactions';
import { createTransactionColumns } from './transaction-columns';
import type { CashTransaction } from './transaction-columns';
@ -46,27 +69,47 @@ type Props = {
};
};
export default function CashAccountIndex({ cashAccount, transactions, filters }: Props) {
export default function CashAccountIndex({
cashAccount,
transactions,
filters,
}: Props) {
const [depositOpen, setDepositOpen] = useState(false);
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [editing, setEditing] = useState<CashTransaction | null>(null);
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(null);
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
null,
);
const [depositUploading, setDepositUploading] = useState(false);
const [depositFileMeta, setDepositFileMeta] = useState<{ size: number; type: string } | null>(null);
const [depositFileMeta, setDepositFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [withdrawalReceiptKey, setWithdrawalReceiptKey] = useState<string | null>(null);
const [withdrawalReceiptKey, setWithdrawalReceiptKey] = useState<
string | null
>(null);
const [withdrawalUploading, setWithdrawalUploading] = useState(false);
const [withdrawalFileMeta, setWithdrawalFileMeta] = useState<{ size: number; type: string } | null>(null);
const [withdrawalFileMeta, setWithdrawalFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
const [editUploading, setEditUploading] = useState(false);
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null);
const [editFileMeta, setEditFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: transactions.current_page,
@ -86,24 +129,32 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
newFilters[key as keyof typeof newFilters] = value;
}
router.get(cashAccountIndex(), {
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
cashAccountIndex(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(cashAccountIndex(), {
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
cashAccountIndex(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
@ -118,49 +169,68 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
}
function handlePageChange(page: number) {
router.get(cashAccountIndex(), {
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
cashAccountIndex(),
{
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(cashAccountIndex(), {
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
cashAccountIndex(),
{
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(cashAccountIndex(), {
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort, filters]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
cashAccountIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(cashAccountIndex(), {
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
cashAccountIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
const columns = createTransactionColumns({
@ -207,7 +277,9 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</label>
<Select
value={filters.type ?? 'all'}
onValueChange={(value) => applyFilter('type', value)}
onValueChange={(value) =>
applyFilter('type', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" />
@ -215,9 +287,15 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="withdrawal">Withdrawal</SelectItem>
<SelectItem value="expense">Pengeluaran</SelectItem>
<SelectItem value="transfer">Transfer</SelectItem>
<SelectItem value="withdrawal">
Withdrawal
</SelectItem>
<SelectItem value="expense">
Pengeluaran
</SelectItem>
<SelectItem value="transfer">
Transfer
</SelectItem>
</SelectContent>
</Select>
</div>
@ -238,11 +316,17 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</h2>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDepositOpen(true)}>
<Button
variant="outline"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="h-4 w-4" />
Deposit
</Button>
<Button variant="outline" onClick={() => setWithdrawalOpen(true)}>
<Button
variant="outline"
onClick={() => setWithdrawalOpen(true)}
>
<ArrowUpFromLine className="h-4 w-4" />
Withdrawal
</Button>
@ -279,20 +363,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
toolbar={filterToolbar}
/>
<Dialog open={depositOpen} onOpenChange={(open) => {
setDepositOpen(open);
<Dialog
open={depositOpen}
onOpenChange={(open) => {
setDepositOpen(open);
if (!open) {
setDepositReceiptKey(null);
setDepositFileMeta(null);
}
}}>
<DialogContent>
<Form action={deposit()} resetOnSuccess onSuccess={() => {
setDepositOpen(false);
if (!open) {
setDepositReceiptKey(null);
setDepositFileMeta(null);
}}>
}
}}
>
<DialogContent>
<Form
action={deposit()}
resetOnSuccess
onSuccess={() => {
setDepositOpen(false);
setDepositReceiptKey(null);
setDepositFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
@ -301,46 +392,95 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
<InputError
message={errors.description}
/>
</div>
<div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="receipt_key" value={depositReceiptKey ?? ''} />
<input type="hidden" name="file_size" value={depositFileMeta?.size ?? ''} />
<input type="hidden" name="file_mime_type" value={depositFileMeta?.type ?? ''} />
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={depositReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
depositFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
depositFileMeta?.type ?? ''
}
/>
<FileUpload
value={depositReceiptKey}
onChange={setDepositReceiptKey}
folder="cash-transaction"
onUploadingChange={setDepositUploading}
onUploadingChange={
setDepositUploading
}
onFileMeta={setDepositFileMeta}
/>
<InputError message={errors.receipt_key} />
<InputError
message={errors.receipt_key}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDepositOpen(false)}>
<Button
type="button"
variant="outline"
onClick={() =>
setDepositOpen(false)
}
>
Batal
</Button>
<Button type="submit" disabled={processing || depositUploading}>
<Button
type="submit"
disabled={
processing || depositUploading
}
>
{processing
? 'Menyimpan...'
: depositUploading
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
@ -349,20 +489,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</DialogContent>
</Dialog>
<Dialog open={withdrawalOpen} onOpenChange={(open) => {
setWithdrawalOpen(open);
<Dialog
open={withdrawalOpen}
onOpenChange={(open) => {
setWithdrawalOpen(open);
if (!open) {
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}
}}>
<DialogContent>
<Form action={withdrawal()} resetOnSuccess onSuccess={() => {
setWithdrawalOpen(false);
if (!open) {
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}}>
}
}}
>
<DialogContent>
<Form
action={withdrawal()}
resetOnSuccess
onSuccess={() => {
setWithdrawalOpen(false);
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
@ -371,46 +518,104 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
<InputError
message={errors.description}
/>
</div>
<div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="receipt_key" value={withdrawalReceiptKey ?? ''} />
<input type="hidden" name="file_size" value={withdrawalFileMeta?.size ?? ''} />
<input type="hidden" name="file_mime_type" value={withdrawalFileMeta?.type ?? ''} />
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
withdrawalReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
withdrawalFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
withdrawalFileMeta?.type ??
''
}
/>
<FileUpload
value={withdrawalReceiptKey}
onChange={setWithdrawalReceiptKey}
onChange={
setWithdrawalReceiptKey
}
folder="cash-transaction"
onUploadingChange={setWithdrawalUploading}
onFileMeta={setWithdrawalFileMeta}
onUploadingChange={
setWithdrawalUploading
}
onFileMeta={
setWithdrawalFileMeta
}
/>
<InputError
message={errors.receipt_key}
/>
<InputError message={errors.receipt_key} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setWithdrawalOpen(false)}>
<Button
type="button"
variant="outline"
onClick={() =>
setWithdrawalOpen(false)
}
>
Batal
</Button>
<Button type="submit" disabled={processing || withdrawalUploading}>
<Button
type="submit"
disabled={
processing ||
withdrawalUploading
}
>
{processing
? 'Menyimpan...'
: withdrawalUploading
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
@ -431,61 +636,121 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
>
<DialogContent>
{editing && (
<Form action={updateTransaction(editing.id)} resetOnSuccess onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}>
<Form
action={updateTransaction(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Edit Transaksi</DialogTitle>
<DialogTitle>
Edit Transaksi
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label>
Keterangan <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
defaultValue={
editing.description
}
/>
<InputError
message={errors.description}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} />
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} />
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} />
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={editReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ?? ''
}
/>
<FileUpload
value={editReceiptKey}
onChange={setEditReceiptKey}
folder="cash-transaction"
onUploadingChange={setEditUploading}
existingUrl={editing.receipt_url}
onUploadingChange={
setEditUploading
}
existingUrl={
editing.receipt_url
}
onFileMeta={setEditFileMeta}
/>
<InputError message={errors.receipt_key} />
<InputError
message={errors.receipt_key}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
<Button
type="button"
variant="outline"
onClick={() => setEditing(null)}
>
Batal
</Button>
<Button type="submit" disabled={processing || editUploading}>
<Button
type="submit"
disabled={
processing || editUploading
}
>
{processing
? 'Menyimpan...'
: editUploading
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>

View File

@ -33,14 +33,18 @@ export type CashTransaction = {
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) + ' ' + date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
function getTypeLabel(type: string): string {
@ -105,9 +109,7 @@ export function createTransactionColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -121,9 +123,7 @@ export function createTransactionColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Tanggal</span>
@ -142,8 +142,14 @@ export function createTransactionColumns(
return (
<div className="flex flex-col">
<span className="font-medium">{getTypeLabel(transaction.type)}</span>
<span className="text-xs text-muted-foreground">{getReferenceLabel(transaction.reference?.type ?? '')}</span>
<span className="font-medium">
{getTypeLabel(transaction.type)}
</span>
<span className="text-xs text-muted-foreground">
{getReferenceLabel(
transaction.reference?.type ?? '',
)}
</span>
</div>
);
},
@ -155,9 +161,7 @@ export function createTransactionColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Jumlah</span>
@ -169,8 +173,15 @@ export function createTransactionColumns(
const isDeposit = transaction.type === 'deposit';
return (
<span className={isDeposit ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
{isDeposit ? '+' : '-'} {formatCurrency(row.getValue('amount') as number)}
<span
className={
isDeposit
? 'font-medium text-green-600'
: 'font-medium text-red-600'
}
>
{isDeposit ? '+' : '-'}{' '}
{formatCurrency(row.getValue('amount') as number)}
</span>
);
},
@ -182,9 +193,7 @@ export function createTransactionColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Saldo Setelah</span>
@ -192,14 +201,18 @@ export function createTransactionColumns(
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{formatCurrency(row.getValue('balance_after') as number)}</span>
<span className="font-medium">
{formatCurrency(row.getValue('balance_after') as number)}
</span>
),
},
{
accessorKey: 'description',
header: () => <span>Keterangan</span>,
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
<span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
),
},
{
@ -212,7 +225,12 @@ export function createTransactionColumns(
return <span className="text-muted-foreground">-</span>;
}
return <ReceiptPreview url={receiptUrl} title={row.original.description} />;
return (
<ReceiptPreview
url={receiptUrl}
title={row.original.description}
/>
);
},
},
{
@ -233,7 +251,9 @@ export function createTransactionColumns(
},
cell: ({ row }) => {
const transaction = row.original;
const canEdit = transaction.type === 'deposit' || transaction.type === 'withdrawal';
const canEdit =
transaction.type === 'deposit' ||
transaction.type === 'withdrawal';
if (!canEdit) {
return <span className="block text-center">-</span>;
@ -252,9 +272,7 @@ export function createTransactionColumns(
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
@ -262,7 +280,9 @@ export function createTransactionColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(transaction)}
onClick={() =>
handleDeleteClick(transaction)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>

View File

@ -1,5 +1,11 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
import {
ArrowUpDown,
CheckCircle,
CircleDollarSign,
Pencil,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@ -30,14 +36,18 @@ export type EmployeeAdvance = {
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) + ' ' + date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
function formatShortDate(dateString: string): string {
@ -100,9 +110,7 @@ export function createEmployeeAdvanceColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -116,9 +124,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Tanggal</span>
@ -135,7 +141,11 @@ export function createEmployeeAdvanceColumns(
cell: ({ row }) => {
const employee = row.original.employee;
return <span>{employee?.user?.user_profile?.full_name ?? '-'}</span>;
return (
<span>
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
},
},
{
@ -145,9 +155,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Jumlah</span>
@ -155,7 +163,7 @@ export function createEmployeeAdvanceColumns(
</Button>
),
cell: ({ row }) => (
<span className="text-red-600 font-medium">
<span className="font-medium text-red-600">
- {formatCurrency(row.getValue('amount') as number)}
</span>
),
@ -164,7 +172,9 @@ export function createEmployeeAdvanceColumns(
accessorKey: 'description',
header: () => <span>Keterangan</span>,
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
<span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
),
},
{
@ -174,9 +184,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Jatuh Tempo</span>
@ -184,7 +192,9 @@ export function createEmployeeAdvanceColumns(
</Button>
),
cell: ({ row }) => (
<span>{formatShortDate(row.getValue('due_date') as string)}</span>
<span>
{formatShortDate(row.getValue('due_date') as string)}
</span>
),
},
{
@ -257,9 +267,7 @@ export function createEmployeeAdvanceColumns(
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -17,7 +17,14 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, index as employeeAdvanceIndex, store, update, approve, pay } from '@/routes/admin/finance/employee-advances';
import {
destroy,
index as employeeAdvanceIndex,
store,
update,
approve,
pay,
} from '@/routes/admin/finance/employee-advances';
import { createEmployeeAdvanceColumns } from './columns';
import type { EmployeeAdvance } from './columns';
@ -38,9 +45,14 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined,
);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: employeeAdvances.current_page,
@ -72,9 +84,13 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
return;
}
router.post(approve(approving.id), {}, {
onSuccess: () => setApproving(null),
});
router.post(
approve(approving.id),
{},
{
onSuccess: () => setApproving(null),
},
);
}
function handlePay() {
@ -82,51 +98,74 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
return;
}
router.post(pay(paying.id), {}, {
onSuccess: () => setPaying(null),
});
router.post(
pay(paying.id),
{},
{
onSuccess: () => setPaying(null),
},
);
}
function handlePageChange(page: number) {
router.get(employeeAdvanceIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeAdvanceIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(employeeAdvanceIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeAdvanceIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(employeeAdvanceIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
employeeAdvanceIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(employeeAdvanceIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
employeeAdvanceIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
const columns = createEmployeeAdvanceColumns({
@ -147,13 +186,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
Kasbon
</h2>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setDueDate(undefined);
}
}}>
if (!open) {
setDueDate(undefined);
}
}}
>
<Button asChild>
<button
type="button"
@ -164,57 +206,98 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Kasbon</DialogTitle>
<DialogTitle>
Tambah Kasbon
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '} <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '} <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="due_date">
Jatuh Tempo{' '} <span className="text-destructive">*</span>
Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input type="hidden" name="due_date" value={dueDate ? dueDate.toISOString().split('T')[0] : ''} />
<input
type="hidden"
name="due_date"
value={
dueDate
? dueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
<InputError
message={
errors.due_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
type="submit"
disabled={processing}
>
{processing
@ -256,43 +339,95 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Kasbon</DialogTitle>
<DialogTitle>
Edit Kasbon
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
<InputError message={errors.amount} />
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label>
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-due_date">Jatuh Tempo{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="due_date" value={editingDueDate ? editingDueDate.toISOString().split('T')[0] : ''} />
<Label htmlFor="edit-due_date">
Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="due_date"
value={
editingDueDate
? editingDueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingDueDate}
onChange={setEditingDueDate}
onChange={
setEditingDueDate
}
placeholder="Pilih jatuh tempo"
min={new Date()}
/>
<InputError message={errors.due_date} />
<InputError
message={
errors.due_date
}
/>
</div>
</div>
<DialogFooter>

View File

@ -28,14 +28,18 @@ export type Expense = {
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) + ' ' + date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
return (
date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}) +
' ' +
date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
}
type CreateColumnsParams = {
@ -78,9 +82,7 @@ export function createExpenseColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -94,9 +96,7 @@ export function createExpenseColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Tanggal</span>
@ -111,7 +111,9 @@ export function createExpenseColumns(
accessorKey: 'description',
header: () => <span>Keterangan</span>,
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
<span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
),
},
{
@ -124,7 +126,12 @@ export function createExpenseColumns(
return <span className="text-muted-foreground">-</span>;
}
return <ReceiptPreview url={receiptUrl} title={row.original.description} />;
return (
<ReceiptPreview
url={receiptUrl}
title={row.original.description}
/>
);
},
},
{
@ -134,9 +141,7 @@ export function createExpenseColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Jumlah</span>
@ -144,7 +149,7 @@ export function createExpenseColumns(
</Button>
),
cell: ({ row }) => (
<span className="text-red-600 font-medium">
<span className="font-medium text-red-600">
- {formatCurrency(row.getValue('amount') as number)}
</span>
),
@ -176,16 +181,12 @@ export function createExpenseColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(expense)
}
onClick={() => handleEdit(expense)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -17,7 +17,12 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, index as expenseIndex, store, update } from '@/routes/admin/finance/expenses';
import {
destroy,
index as expenseIndex,
store,
update,
} from '@/routes/admin/finance/expenses';
import { createExpenseColumns } from './columns';
import type { Expense } from './columns';
@ -35,14 +40,25 @@ export default function ExpenseIndex({ expenses }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Expense | null>(null);
const [deleting, setDeleting] = useState<Expense | null>(null);
const [createReceiptKey, setCreateReceiptKey] = useState<string | null>(null);
const [createReceiptKey, setCreateReceiptKey] = useState<string | null>(
null,
);
const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
const [createUploading, setCreateUploading] = useState(false);
const [editUploading, setEditUploading] = useState(false);
const [createFileMeta, setCreateFileMeta] = useState<{ size: number; type: string } | null>(null);
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null);
const [createFileMeta, setCreateFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [editFileMeta, setEditFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: expenses.current_page,
@ -52,45 +68,64 @@ export default function ExpenseIndex({ expenses }: Props) {
};
function handlePageChange(page: number) {
router.get(expenseIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
expenseIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(expenseIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
expenseIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(expenseIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
expenseIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(expenseIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
expenseIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
function handleDelete() {
@ -122,14 +157,17 @@ export default function ExpenseIndex({ expenses }: Props) {
Pengeluaran
</h2>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setCreateReceiptKey(null);
setCreateFileMeta(null);
}
}}>
if (!open) {
setCreateReceiptKey(null);
setCreateFileMeta(null);
}
}}
>
<Button asChild>
<button
type="button"
@ -140,69 +178,130 @@ export default function ExpenseIndex({ expenses }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => {
setCreateOpen(false);
setCreateReceiptKey(null);
setCreateFileMeta(null);
}}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => {
setCreateOpen(false);
setCreateReceiptKey(null);
setCreateFileMeta(null);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Pengeluaran</DialogTitle>
<DialogTitle>
Tambah Pengeluaran
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jumlah{' '} <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan{' '} <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
<InputError
message={
errors.description
}
/>
</div>
<div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="receipt_key" value={createReceiptKey ?? ''} />
<input type="hidden" name="file_size" value={createFileMeta?.size ?? ''} />
<input type="hidden" name="file_mime_type" value={createFileMeta?.type ?? ''} />
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
createReceiptKey ??
''
}
/>
<input
type="hidden"
name="file_size"
value={
createFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
createFileMeta?.type ??
''
}
/>
<FileUpload
value={createReceiptKey}
onChange={setCreateReceiptKey}
onChange={
setCreateReceiptKey
}
folder="expense"
onUploadingChange={setCreateUploading}
onFileMeta={setCreateFileMeta}
onUploadingChange={
setCreateUploading
}
onFileMeta={
setCreateFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/>
<InputError message={errors.receipt_key} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
disabled={processing || createUploading}
type="submit"
disabled={
processing ||
createUploading
}
>
{processing
? 'Menyimpan...'
: createUploading
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
@ -240,48 +339,114 @@ export default function ExpenseIndex({ expenses }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Pengeluaran</DialogTitle>
<DialogTitle>
Edit Pengeluaran
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
<InputError message={errors.amount} />
<Label>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label>
<Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-description"
name="description"
placeholder="Masukkan keterangan"
defaultValue={editing.description}
defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} />
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} />
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} />
<Label>
Bukti{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
editReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ??
''
}
/>
<FileUpload
value={editReceiptKey}
onChange={setEditReceiptKey}
onChange={
setEditReceiptKey
}
folder="expense"
onUploadingChange={setEditUploading}
existingUrl={editing.receipt_url}
onFileMeta={setEditFileMeta}
onUploadingChange={
setEditUploading
}
existingUrl={
editing.receipt_url
}
onFileMeta={
setEditFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/>
<InputError message={errors.receipt_key} />
</div>
</div>
<DialogFooter>
@ -296,13 +461,16 @@ export default function ExpenseIndex({ expenses }: Props) {
</Button>
<Button
type="submit"
disabled={processing || editUploading}
disabled={
processing ||
editUploading
}
>
{processing
? 'Menyimpan...'
: editUploading
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</DialogFooter>
</>

View File

@ -27,8 +27,19 @@ export type PayrollPeriod = {
};
const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
function formatPeriod(period: PayrollPeriod): string {
@ -72,9 +83,7 @@ export function createPayrollPeriodColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -88,9 +97,7 @@ export function createPayrollPeriodColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Periode</span>
@ -98,14 +105,18 @@ export function createPayrollPeriodColumns(
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{formatPeriod(row.original)}</span>
<span className="font-medium">
{formatPeriod(row.original)}
</span>
),
},
{
accessorKey: 'payrolls_count',
header: () => <span>Jumlah Karyawan</span>,
cell: ({ row }) => (
<span className="text-center">{row.getValue('payrolls_count') as number}</span>
<span className="text-center">
{row.getValue('payrolls_count') as number}
</span>
),
},
{
@ -115,9 +126,7 @@ export function createPayrollPeriodColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Total Gaji</span>
@ -126,7 +135,10 @@ export function createPayrollPeriodColumns(
),
cell: ({ row }) => (
<span className="font-medium">
{formatCurrency((row.getValue('payrolls_sum_total_amount') as number) ?? 0)}
{formatCurrency(
(row.getValue('payrolls_sum_total_amount') as number) ??
0,
)}
</span>
),
},
@ -137,9 +149,7 @@ export function createPayrollPeriodColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Bonus</span>
@ -147,11 +157,19 @@ export function createPayrollPeriodColumns(
</Button>
),
cell: ({ row }) => {
const value = (row.getValue('payrolls_sum_bonus_amount') as number) ?? 0;
const value =
(row.getValue('payrolls_sum_bonus_amount') as number) ?? 0;
return (
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
<span
className={
value > 0
? 'font-medium text-green-600'
: 'text-muted-foreground'
}
>
{value > 0 ? '+ ' : ''}
{formatCurrency(value)}
</span>
);
},
@ -163,9 +181,7 @@ export function createPayrollPeriodColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Potongan</span>
@ -173,11 +189,20 @@ export function createPayrollPeriodColumns(
</Button>
),
cell: ({ row }) => {
const value = (row.getValue('payrolls_sum_deduction_amount') as number) ?? 0;
const value =
(row.getValue('payrolls_sum_deduction_amount') as number) ??
0;
return (
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}>
{value > 0 ? '- ' : ''}{formatCurrency(value)}
<span
className={
value > 0
? 'font-medium text-red-600'
: 'text-muted-foreground'
}
>
{value > 0 ? '- ' : ''}
{formatCurrency(value)}
</span>
);
},
@ -195,13 +220,19 @@ export function createPayrollPeriodColumns(
return (
<div className="flex flex-col gap-0.5 text-xs">
{paid > 0 && (
<span className="text-green-600">{paid} dibayar</span>
<span className="text-green-600">
{paid} dibayar
</span>
)}
{unpaid > 0 && (
<span className="text-yellow-600">{unpaid} menunggu</span>
<span className="text-yellow-600">
{unpaid} menunggu
</span>
)}
{cancelled > 0 && (
<span className="text-red-600">{cancelled} dibatalkan</span>
<span className="text-red-600">
{cancelled} dibatalkan
</span>
)}
</div>
);
@ -229,11 +260,7 @@ export function createPayrollPeriodColumns(
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
>
<Button variant="ghost" size="icon" asChild>
<Link href={showUrl(period.id)}>
<Eye className="h-4 w-4" />
</Link>

View File

@ -28,15 +28,29 @@ type Props = {
};
const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: payrollPeriods.current_page,
@ -47,64 +61,91 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
function handleClose() {
if (!closing) {
return;
}
return;
}
router.post(close(closing.id), {}, {
onSuccess: () => setClosing(null),
});
router.post(
close(closing.id),
{},
{
onSuccess: () => setClosing(null),
},
);
}
function handleReopen() {
if (!reopening) {
return;
}
return;
}
router.post(reopen(reopening.id), {}, {
onSuccess: () => setReopening(null),
});
router.post(
reopen(reopening.id),
{},
{
onSuccess: () => setReopening(null),
},
);
}
function handlePageChange(page: number) {
router.get(payrollPeriodsIndex(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
payrollPeriodsIndex(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(payrollPeriodsIndex(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
payrollPeriodsIndex(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(payrollPeriodsIndex(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
payrollPeriodsIndex(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(payrollPeriodsIndex(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
payrollPeriodsIndex(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
const columns = createPayrollPeriodColumns({
@ -145,8 +186,8 @@ return;
open={closing !== null}
onOpenChange={(open) => {
if (!open) {
setClosing(null);
}
setClosing(null);
}
}}
title="Tutup Periode Gaji"
description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`}
@ -158,8 +199,8 @@ setClosing(null);
open={reopening !== null}
onOpenChange={(open) => {
if (!open) {
setReopening(null);
}
setReopening(null);
}
}}
title="Buka Periode Gaji"
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}

View File

@ -1,5 +1,11 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react';
import {
ArrowUpDown,
CircleDollarSign,
Pencil,
Trash2,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@ -65,22 +71,28 @@ type CreateColumnsParams = {
handlePay: (payroll: Payroll) => void;
handleCancel: (payroll: Payroll) => void;
handleAddAdjustment: (payroll: Payroll) => void;
handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void;
handleDeleteAdjustment: (
adjustment: PayrollAdjustment,
payrollId: number,
) => void;
};
export function createPayrollColumns(
params: CreateColumnsParams,
): ColumnDef<Payroll>[] {
const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params;
const {
handlePay,
handleCancel,
handleAddAdjustment,
handleDeleteAdjustment,
} = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -93,7 +105,11 @@ export function createPayrollColumns(
cell: ({ row }) => {
const employee = row.original.employee;
return <span className="font-medium">{employee?.user?.user_profile?.full_name ?? '-'}</span>;
return (
<span className="font-medium">
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
},
},
{
@ -103,9 +119,7 @@ export function createPayrollColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Gaji Pokok</span>
@ -113,7 +127,9 @@ export function createPayrollColumns(
</Button>
),
cell: ({ row }) => (
<span>{formatCurrency(row.getValue('base_salary') as number)}</span>
<span>
{formatCurrency(row.getValue('base_salary') as number)}
</span>
),
},
{
@ -123,9 +139,7 @@ export function createPayrollColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Bonus</span>
@ -136,8 +150,13 @@ export function createPayrollColumns(
const value = row.getValue('bonus_amount') as number;
return (
<span className={value > 0 ? 'text-green-600 font-medium' : ''}>
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
<span
className={
value > 0 ? 'font-medium text-green-600' : ''
}
>
{value > 0 ? '+ ' : ''}
{formatCurrency(value)}
</span>
);
},
@ -149,9 +168,7 @@ export function createPayrollColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Potongan</span>
@ -162,8 +179,11 @@ export function createPayrollColumns(
const value = row.getValue('deduction_amount') as number;
return (
<span className={value > 0 ? 'text-red-600 font-medium' : ''}>
{value > 0 ? '- ' : ''}{formatCurrency(value)}
<span
className={value > 0 ? 'font-medium text-red-600' : ''}
>
{value > 0 ? '- ' : ''}
{formatCurrency(value)}
</span>
);
},
@ -175,9 +195,7 @@ export function createPayrollColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Total</span>
@ -185,7 +203,9 @@ export function createPayrollColumns(
</Button>
),
cell: ({ row }) => (
<span className="font-semibold">{formatCurrency(row.getValue('total_amount') as number)}</span>
<span className="font-semibold">
{formatCurrency(row.getValue('total_amount') as number)}
</span>
),
},
{
@ -209,16 +229,31 @@ export function createPayrollColumns(
return (
<div className="flex flex-col gap-1">
{adjustments.map((adj: PayrollAdjustment) => (
<div key={adj.id} className="flex items-center gap-1 text-xs">
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-600'}>
{adj.type === 'bonus' ? '+' : '-'} {formatCurrency(adj.amount)}
<div
key={adj.id}
className="flex items-center gap-1 text-xs"
>
<span
className={
adj.type === 'bonus'
? 'text-green-600'
: 'text-red-600'
}
>
{adj.type === 'bonus' ? '+' : '-'}{' '}
{formatCurrency(adj.amount)}
</span>
<span className="text-muted-foreground truncate max-w-[100px]">
<span className="max-w-[100px] truncate text-muted-foreground">
{adj.description}
</span>
{payroll.status === 'unpaid' && (
<button
onClick={() => handleDeleteAdjustment(adj, payroll.id)}
onClick={() =>
handleDeleteAdjustment(
adj,
payroll.id,
)
}
className="text-destructive hover:text-destructive/80"
>
<XCircle className="h-3 w-3" />
@ -250,7 +285,9 @@ export function createPayrollColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleAddAdjustment(payroll)}
onClick={() =>
handleAddAdjustment(payroll)
}
>
<Pencil className="h-4 w-4" />
</Button>
@ -265,7 +302,9 @@ export function createPayrollColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handlePay(payroll)}
onClick={() =>
handlePay(payroll)
}
>
<CircleDollarSign className="h-4 w-4 text-green-600" />
</Button>
@ -280,7 +319,9 @@ export function createPayrollColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleCancel(payroll)}
onClick={() =>
handleCancel(payroll)
}
>
<XCircle className="h-4 w-4 text-destructive" />
</Button>

View File

@ -15,9 +15,7 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { formatCurrency } from '@/lib/utils';
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
import {
index as payrollPeriodsIndex
} from '@/routes/admin/finance/payroll-periods';
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
import {
cancel as payrollCancel,
pay as payrollPay,
@ -40,31 +38,55 @@ type Props = {
};
const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const [paying, setPaying] = useState<Payroll | null>(null);
const [cancelling, setCancelling] = useState<Payroll | null>(null);
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(null);
const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null);
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
null,
);
const [deletingAdjustment, setDeletingAdjustment] = useState<{
adjustment: PayrollAdjustment;
payrollId: number;
} | null>(null);
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
function handlePay() {
if (!paying) return;
router.post(payrollPay(paying.id), {}, {
onSuccess: () => setPaying(null),
});
router.post(
payrollPay(paying.id),
{},
{
onSuccess: () => setPaying(null),
},
);
}
function handleCancel() {
if (!cancelling) return;
router.post(payrollCancel(cancelling.id), {}, {
onSuccess: () => setCancelling(null),
});
router.post(
payrollCancel(cancelling.id),
{},
{
onSuccess: () => setCancelling(null),
},
);
}
function handleDeleteAdjustment() {
@ -87,26 +109,45 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
},
});
const totalBaseSalary = payrollPeriod.payrolls.reduce((sum, p) => sum + p.base_salary, 0);
const totalBonus = payrollPeriod.payrolls.reduce((sum, p) => sum + p.bonus_amount, 0);
const totalDeduction = payrollPeriod.payrolls.reduce((sum, p) => sum + p.deduction_amount, 0);
const totalAmount = payrollPeriod.payrolls.reduce((sum, p) => sum + p.total_amount, 0);
const totalBaseSalary = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.base_salary,
0,
);
const totalBonus = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.bonus_amount,
0,
);
const totalDeduction = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.deduction_amount,
0,
);
const totalAmount = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.total_amount,
0,
);
return (
<>
<Head title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`} />
<Head
title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`}
/>
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year}
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
{payrollPeriod.year}
</h2>
<p className="text-sm text-muted-foreground">
{payrollPeriod.payrolls.length} karyawan &middot; Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'}
{payrollPeriod.payrolls.length} karyawan &middot;
Status:{' '}
{payrollPeriod.status === 'open'
? 'Terbuka'
: 'Ditutup'}
</p>
</div>
<Button asChild variant='outline'>
<Button asChild variant="outline">
<a href={payrollPeriodsIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
@ -116,20 +157,36 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Gaji Pokok</p>
<p className="text-lg font-semibold">{formatCurrency(totalBaseSalary)}</p>
<p className="text-sm text-muted-foreground">
Total Gaji Pokok
</p>
<p className="text-lg font-semibold">
{formatCurrency(totalBaseSalary)}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Bonus</p>
<p className="text-lg font-semibold text-green-600">{formatCurrency(totalBonus)}</p>
<p className="text-sm text-muted-foreground">
Total Bonus
</p>
<p className="text-lg font-semibold text-green-600">
{formatCurrency(totalBonus)}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Potongan</p>
<p className="text-lg font-semibold text-red-600">{formatCurrency(totalDeduction)}</p>
<p className="text-sm text-muted-foreground">
Total Potongan
</p>
<p className="text-lg font-semibold text-red-600">
{formatCurrency(totalDeduction)}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Gaji</p>
<p className="text-lg font-semibold">{formatCurrency(totalAmount)}</p>
<p className="text-sm text-muted-foreground">
Total Gaji
</p>
<p className="text-lg font-semibold">
{formatCurrency(totalAmount)}
</p>
</div>
</div>
@ -142,12 +199,15 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
/>
{/* Dialog Tambah Penyesuaian */}
<Dialog open={addingAdjustment !== null} onOpenChange={(open) => {
if (!open) {
setAddingAdjustment(null);
setAdjustmentType('bonus');
}
}}>
<Dialog
open={addingAdjustment !== null}
onOpenChange={(open) => {
if (!open) {
setAddingAdjustment(null);
setAdjustmentType('bonus');
}
}}
>
<DialogContent>
{addingAdjustment && (
<Form
@ -161,59 +221,108 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Tambah Penyesuaian</DialogTitle>
<DialogTitle>
Tambah Penyesuaian
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Jenis{' '} <span className="text-destructive">*</span>
Jenis{' '}
<span className="text-destructive">
*
</span>
</Label>
<input type="hidden" name="type" value={adjustmentType} />
<input
type="hidden"
name="type"
value={adjustmentType}
/>
<RadioGroup
value={adjustmentType}
onValueChange={setAdjustmentType}
onValueChange={
setAdjustmentType
}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="bonus" id="bonus" />
<Label htmlFor="bonus" className="font-normal">Bonus</Label>
<RadioGroupItem
value="bonus"
id="bonus"
/>
<Label
htmlFor="bonus"
className="font-normal"
>
Bonus
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="deduction" id="deduction" />
<Label htmlFor="deduction" className="font-normal">Potongan</Label>
<RadioGroupItem
value="deduction"
id="deduction"
/>
<Label
htmlFor="deduction"
className="font-normal"
>
Potongan
</Label>
</div>
</RadioGroup>
<InputError message={errors.type} />
<InputError
message={errors.type}
/>
</div>
<div className="grid gap-2">
<Label>
Jumlah{' '} <span className="text-destructive">*</span>
Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="amount" min={1} />
<InputError message={errors.amount} />
<RupiahInput
name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="adjustment-description">
Keterangan{' '} <span className="text-destructive">*</span>
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="adjustment-description"
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
<InputError
message={errors.description}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddingAdjustment(null)}
onClick={() =>
setAddingAdjustment(null)
}
>
Batal
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>

View File

@ -3,12 +3,32 @@ import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { index as attendanceIndex, store, update } from '@/routes/admin/hr/attendances';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
index as attendanceIndex,
store,
update,
} from '@/routes/admin/hr/attendances';
import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
import { CalendarCheck, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, LogIn, LogOut, UserX, Wallet } from 'lucide-react';
import {
CalendarCheck,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
LogIn,
LogOut,
UserX,
Wallet,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
@ -56,12 +76,20 @@ function getFirstDayOfMonth(year: number, month: number): number {
}
function isSameDay(d1: Date, d2: Date): boolean {
return d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear();
return (
d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear()
);
}
function checkIsToday(date: Date): boolean {
const t = new Date();
return date.getDate() === t.getDate() && date.getMonth() === t.getMonth() && date.getFullYear() === t.getFullYear();
return (
date.getDate() === t.getDate() &&
date.getMonth() === t.getMonth() &&
date.getFullYear() === t.getFullYear()
);
}
function isWeekend(date: Date): boolean {
@ -69,7 +97,11 @@ function isWeekend(date: Date): boolean {
return day === 0 || day === 6;
}
function isLate(checkInAt: string | null, officeHour: number, officeMinute: number): boolean {
function isLate(
checkInAt: string | null,
officeHour: number,
officeMinute: number,
): boolean {
if (!checkInAt) return false;
const d = new Date(checkInAt);
const h = d.getHours();
@ -77,7 +109,11 @@ function isLate(checkInAt: string | null, officeHour: number, officeMinute: numb
return h > officeHour || (h === officeHour && m > officeMinute);
}
function getLateMinutes(checkInAt: string | null, officeHour: number, officeMinute: number): number {
function getLateMinutes(
checkInAt: string | null,
officeHour: number,
officeMinute: number,
): number {
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
const d = new Date(checkInAt);
const officeStart = new Date(d);
@ -93,14 +129,29 @@ function formatMinutes(minutes: number | null): string {
return `${hours} jam ${mins} menit`;
}
export default function AttendanceIndex({ attendances, todayAttendance, currentYear, currentMonth, monthStats, hrSettings }: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time.split(':').map(Number);
const [viewDate, setViewDate] = useState(new Date(currentYear, currentMonth - 1));
export default function AttendanceIndex({
attendances,
todayAttendance,
currentYear,
currentMonth,
monthStats,
hrSettings,
}: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
.split(':')
.map(Number);
const [viewDate, setViewDate] = useState(
new Date(currentYear, currentMonth - 1),
);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [showCamera, setShowCamera] = useState(false);
const [actionType, setActionType] = useState<'check-in' | 'check-out'>('check-in');
const [actionType, setActionType] = useState<'check-in' | 'check-out'>(
'check-in',
);
const [locationLoading, setLocationLoading] = useState(false);
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(null);
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
null,
);
const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1;
@ -121,7 +172,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
const calendarDays = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
const prevMonthDays = getDaysInMonth(viewYear, viewMonth === 1 ? 12 : viewMonth - 1);
const prevMonthDays = getDaysInMonth(
viewYear,
viewMonth === 1 ? 12 : viewMonth - 1,
);
const days: { day: number; isCurrentMonth: boolean; date: Date }[] = [];
@ -129,18 +183,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
const d = prevMonthDays - i;
const m = viewMonth === 1 ? 12 : viewMonth - 1;
const y = viewMonth === 1 ? viewYear - 1 : viewYear;
days.push({ day: d, isCurrentMonth: false, date: new Date(y, m - 1, d) });
days.push({
day: d,
isCurrentMonth: false,
date: new Date(y, m - 1, d),
});
}
for (let i = 1; i <= daysInMonth; i++) {
days.push({ day: i, isCurrentMonth: true, date: new Date(viewYear, viewMonth - 1, i) });
days.push({
day: i,
isCurrentMonth: true,
date: new Date(viewYear, viewMonth - 1, i),
});
}
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
const m = viewMonth === 12 ? 1 : viewMonth + 1;
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
days.push({ day: i, isCurrentMonth: false, date: new Date(y, m - 1, i) });
days.push({
day: i,
isCurrentMonth: false,
date: new Date(y, m - 1, i),
});
}
return days;
@ -164,20 +230,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
setLocationLoading(false);
const formData = new FormData();
formData.append('photo', dataUrl);
formData.append('latitude', position.coords.latitude.toString());
formData.append('longitude', position.coords.longitude.toString());
formData.append(
'latitude',
position.coords.latitude.toString(),
);
formData.append(
'longitude',
position.coords.longitude.toString(),
);
if (actionType === 'check-in') {
router.post(store(), formData, { preserveScroll: true });
} else if (actionType === 'check-out' && todayAttendance) {
router.put(update(todayAttendance.id), formData, { preserveScroll: true });
router.put(update(todayAttendance.id), formData, {
preserveScroll: true,
});
}
},
() => {
setLocationLoading(false);
toast.error('Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.');
toast.error(
'Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.',
);
},
{ enableHighAccuracy: true, timeout: 10000 }
{ enableHighAccuracy: true, timeout: 10000 },
);
};
@ -205,7 +281,9 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Presensi</h2>
<h2 className="text-2xl font-semibold tracking-tight">
Presensi
</h2>
</div>
{/* Alert Status Presensi */}
@ -219,33 +297,53 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Masuk:</span>
<span className="font-medium">{formatTime(todayAttendance?.check_in_at)}</span>
<span className="text-muted-foreground">
Masuk:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_in_at,
)}
</span>
</div>
<div className="flex items-center gap-1.5">
{hasCheckedOut ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Pulang:</span>
<span className="font-medium">{formatTime(todayAttendance?.check_out_at)}</span>
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_out_at,
)}
</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">Pulang:</span>
<span className="font-medium text-orange-600">Belum</span>
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium text-orange-600">
Belum
</span>
</>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Anda belum melakukan presensi hari ini</p>
<p className="text-sm text-muted-foreground">
Anda belum melakukan presensi hari ini
</p>
)}
</div>
<div className="flex items-center gap-2">
{locationLoading && (
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span>
<span className="text-xs text-muted-foreground">
Mendapatkan lokasi...
</span>
)}
<Button
size="sm"
@ -259,7 +357,11 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
size="sm"
variant="outline"
onClick={handleCheckOut}
disabled={!hasCheckedIn || hasCheckedOut || locationLoading}
disabled={
!hasCheckedIn ||
hasCheckedOut ||
locationLoading
}
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
@ -277,8 +379,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<CalendarDays className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Hari Kerja</p>
<p className="text-lg font-bold">{monthStats.working_days}</p>
<p className="text-xs text-muted-foreground">
Hari Kerja
</p>
<p className="text-lg font-bold">
{monthStats.working_days}
</p>
</div>
</CardContent>
</Card>
@ -288,8 +394,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Hadir</p>
<p className="text-lg font-bold">{monthStats.present}</p>
<p className="text-xs text-muted-foreground">
Hadir
</p>
<p className="text-lg font-bold">
{monthStats.present}
</p>
</div>
</CardContent>
</Card>
@ -299,8 +409,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<UserX className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Tidak Hadir</p>
<p className="text-lg font-bold">{monthStats.absent}</p>
<p className="text-xs text-muted-foreground">
Tidak Hadir
</p>
<p className="text-lg font-bold">
{monthStats.absent}
</p>
</div>
</CardContent>
</Card>
@ -310,41 +424,72 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<Wallet className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">Cuti</p>
<p className="text-lg font-bold">{monthStats.leave}</p>
<p className="text-xs text-muted-foreground">
Cuti
</p>
<p className="text-lg font-bold">
{monthStats.leave}
</p>
</div>
</CardContent>
</Card>
</div>
<Card className="overflow-hidden" style={{ '--card-spacing': '0px' } as React.CSSProperties}>
<Card
className="overflow-hidden"
style={{ '--card-spacing': '0px' } as React.CSSProperties}
>
{/* Calendar Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
<div className="bg-muted px-3 py-0.5">
<span className="text-xs font-semibold uppercase text-muted-foreground">
{format(viewDate, 'MMM', { locale: id })}
<span className="text-xs font-semibold text-muted-foreground uppercase">
{format(viewDate, 'MMM', {
locale: id,
})}
</span>
</div>
<div className="px-3 py-1">
<span className="text-lg font-bold text-primary">{format(viewDate, 'dd')}</span>
<span className="text-lg font-bold text-primary">
{format(viewDate, 'dd')}
</span>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
{format(viewDate, 'MMMM yyyy', { locale: id })}
{format(viewDate, 'MMMM yyyy', {
locale: id,
})}
</h3>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevMonth}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handlePrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-3 text-xs font-medium" onClick={() => { setViewDate(new Date()); setSelectedDate(new Date()); }}>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs font-medium"
onClick={() => {
setViewDate(new Date());
setSelectedDate(new Date());
}}
>
Hari ini
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextMonth}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@ -353,7 +498,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{/* Weekday Headers */}
<div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day) => (
<div key={day} className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground">
<div
key={day}
className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground"
>
{day}
</div>
))}
@ -364,8 +512,14 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{calendarDays.map((cell, idx) => {
const dateStr = format(cell.date, 'yyyy-MM-dd');
const attendance = attendanceDates.get(dateStr);
const isSelected = isSameDay(cell.date, selectedDate);
const isTodayDate = isSameDay(cell.date, new Date());
const isSelected = isSameDay(
cell.date,
selectedDate,
);
const isTodayDate = isSameDay(
cell.date,
new Date(),
);
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -373,20 +527,39 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < today;
const showAbsent = cell.isCurrentMonth && isPastDate && !isWeekend(cell.date) && !attendance;
const showAbsent =
cell.isCurrentMonth &&
isPastDate &&
!isWeekend(cell.date) &&
!attendance;
const late = attendance ? isLate(attendance.check_in_at, officeHour, officeMinute) : false;
const lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0;
const late = attendance
? isLate(
attendance.check_in_at,
officeHour,
officeMinute,
)
: false;
const lateMins = attendance
? getLateMinutes(
attendance.check_in_at,
officeHour,
officeMinute,
)
: 0;
return (
<button
key={idx}
onClick={() => {
setSelectedDate(cell.date);
if (attendance) setDetailAttendance(attendance);
if (attendance)
setDetailAttendance(attendance);
}}
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
!cell.isCurrentMonth ? 'bg-muted/30 text-muted-foreground/50' : ''
!cell.isCurrentMonth
? 'bg-muted/30 text-muted-foreground/50'
: ''
}`}
style={{
borderRight: '1px solid var(--border)',
@ -399,8 +572,8 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
isSelected
? 'bg-primary text-primary-foreground'
: isTodayDate
? 'bg-muted text-foreground'
: 'text-foreground'
? 'bg-muted text-foreground'
: 'text-foreground'
}`}
>
{cell.day}
@ -409,15 +582,40 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="mt-1 flex flex-col gap-0.5">
{attendance && (
<>
<span className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
{late ? 'Terlambat' : 'Hadir'}
<span
className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
>
{late
? 'Terlambat'
: 'Hadir'}
</span>
<span className="text-[10px] text-muted-foreground">
Masuk :{' '}
{formatTime(
attendance.check_in_at,
)}
</span>
<span className="text-[10px] text-muted-foreground">
Pulang :{' '}
{formatTime(
attendance.check_out_at,
)}
</span>
<span className="text-[10px] text-muted-foreground">Masuk : {formatTime(attendance.check_in_at)}</span>
<span className="text-[10px] text-muted-foreground">Pulang : {formatTime(attendance.check_out_at)}</span>
{late && (
<span className="text-[10px] text-yellow-600">Telat : {formatMinutes(lateMins)} menit</span>
<span className="text-[10px] text-yellow-600">
Telat :{' '}
{formatMinutes(
lateMins,
)}{' '}
menit
</span>
)}
<span className="text-[10px] text-muted-foreground">Jam Kerja : {formatMinutes(attendance.work_duration_minutes)}</span>
<span className="text-[10px] text-muted-foreground">
Jam Kerja :{' '}
{formatMinutes(
attendance.work_duration_minutes,
)}
</span>
</>
)}
{showAbsent && (
@ -440,21 +638,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
/>
)}
<Dialog open={!!detailAttendance} onOpenChange={(open) => !open && setDetailAttendance(null)}>
<Dialog
open={!!detailAttendance}
onOpenChange={(open) => !open && setDetailAttendance(null)}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Detail Presensi - {detailAttendance && format(new Date(detailAttendance.attendance_date), 'dd MMMM yyyy', { locale: id })}
Detail Presensi -{' '}
{detailAttendance &&
format(
new Date(detailAttendance.attendance_date),
'dd MMMM yyyy',
{ locale: id },
)}
</DialogTitle>
</DialogHeader>
{detailAttendance && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Masuk</span>
<span className="text-sm font-medium text-muted-foreground">
Foto Masuk
</span>
{detailAttendance.check_in_photo ? (
<img
src={detailAttendance.check_in_photo}
src={
detailAttendance.check_in_photo
}
alt="Foto Masuk"
className="w-full rounded-lg border object-cover"
/>
@ -465,14 +676,22 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
)}
<div className="flex items-center gap-2 text-sm">
<LogIn className="h-4 w-4 text-green-600" />
<span className="font-medium">{formatTime(detailAttendance.check_in_at)}</span>
<span className="font-medium">
{formatTime(
detailAttendance.check_in_at,
)}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Pulang</span>
<span className="text-sm font-medium text-muted-foreground">
Foto Pulang
</span>
{detailAttendance.check_out_photo ? (
<img
src={detailAttendance.check_out_photo}
src={
detailAttendance.check_out_photo
}
alt="Foto Pulang"
className="w-full rounded-lg border object-cover"
/>
@ -485,22 +704,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{detailAttendance.check_out_at ? (
<>
<LogOut className="h-4 w-4 text-blue-600" />
<span className="font-medium">{formatTime(detailAttendance.check_out_at)}</span>
<span className="font-medium">
{formatTime(
detailAttendance.check_out_at,
)}
</span>
</>
) : (
<>
<Clock className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">Belum pulang</span>
<span className="font-medium text-orange-600">
Belum pulang
</span>
</>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Lokasi Presensi</span>
<span className="text-sm font-medium text-muted-foreground">
Lokasi Presensi
</span>
<LocationMap
latitude={detailAttendance.check_in_latitude}
longitude={detailAttendance.check_in_longitude}
latitude={
detailAttendance.check_in_latitude
}
longitude={
detailAttendance.check_in_longitude
}
height="200px"
/>
</div>

View File

@ -56,16 +56,19 @@ type CreateColumnsParams = {
export function createEmployeeColumns(
params: CreateColumnsParams,
): ColumnDef<Employee>[] {
const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params;
const {
handleEdit,
handleDeleteClick,
handleResetPassword,
toggleActiveUrl,
} = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -80,9 +83,7 @@ export function createEmployeeColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama</span>
@ -111,9 +112,7 @@ export function createEmployeeColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Username</span>
@ -133,7 +132,9 @@ export function createEmployeeColumns(
cell: ({ row }) => {
const employee = row.original;
return <span>{employee.user_profile?.phone_number ?? '-'}</span>;
return (
<span>{employee.user_profile?.phone_number ?? '-'}</span>
);
},
},
{
@ -144,9 +145,7 @@ export function createEmployeeColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Status</span>
@ -158,7 +157,9 @@ export function createEmployeeColumns(
return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getEmploymentStatusLabel(employee.employee?.employment_status ?? '')}
{getEmploymentStatusLabel(
employee.employee?.employment_status ?? '',
)}
</span>
);
},
@ -179,9 +180,13 @@ export function createEmployeeColumns(
size="sm"
checked={employee.is_active}
onCheckedChange={() => {
router.post(toggleActiveUrl(employee.id), {}, {
preserveScroll: true,
});
router.post(
toggleActiveUrl(employee.id),
{},
{
preserveScroll: true,
},
);
}}
/>
</div>
@ -211,9 +216,7 @@ export function createEmployeeColumns(
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
@ -221,7 +224,9 @@ export function createEmployeeColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleResetPassword(employee)}
onClick={() =>
handleResetPassword(employee)
}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
</Button>
@ -236,7 +241,9 @@ export function createEmployeeColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(employee)}
onClick={() =>
handleDeleteClick(employee)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>

View File

@ -36,7 +36,7 @@ export default function EmployeeCreate() {
Tambah Pegawai
</h2>
</div>
<Button asChild variant='outline'>
<Button asChild variant="outline">
<a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
@ -52,9 +52,7 @@ export default function EmployeeCreate() {
</AlertDescription>
</Alert>
<Form
action={store()}
>
<Form action={store()}>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
@ -65,7 +63,10 @@ export default function EmployeeCreate() {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
Email{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="email"
@ -73,18 +74,25 @@ export default function EmployeeCreate() {
type="email"
placeholder="Masukkan email"
/>
<InputError message={errors.email} />
<InputError
message={errors.email}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
Username{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="username"
name="username"
placeholder="Masukkan username"
/>
<InputError message={errors.username} />
<InputError
message={errors.username}
/>
</div>
</CardContent>
</Card>
@ -96,35 +104,63 @@ export default function EmployeeCreate() {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
/>
<InputError message={errors.full_name} />
<InputError
message={errors.full_name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number"/>
<InputError message={errors.phone_number} />
<PhoneNumberInput name="phone_number" />
<InputError
message={errors.phone_number}
/>
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup name="gender" className="flex gap-4">
<RadioGroup
name="gender"
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" />
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
<RadioGroupItem
value="male"
id="gender-male"
/>
<Label
htmlFor="gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" />
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
<RadioGroupItem
value="female"
id="gender-female"
/>
<Label
htmlFor="gender-female"
className="font-normal"
>
Perempuan
</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
<InputError
message={errors.gender}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
@ -134,7 +170,9 @@ export default function EmployeeCreate() {
onChange={() => {}}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
<InputError
message={errors.birth_date}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
@ -146,7 +184,9 @@ export default function EmployeeCreate() {
placeholder="Masukkan alamat"
rows={3}
/>
<InputError message={errors.address} />
<InputError
message={errors.address}
/>
</div>
</CardContent>
</Card>
@ -157,14 +197,21 @@ export default function EmployeeCreate() {
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label>
<Label>
Tanggal Masuk{' '}
<span className="text-destructive">
*
</span>
</Label>
<DatePicker
name="join_date"
value={joinDate}
onChange={setJoinDate}
placeholder="Pilih tanggal masuk"
/>
<InputError message={errors.join_date} />
<InputError
message={errors.join_date}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Keluar</Label>
@ -174,36 +221,68 @@ export default function EmployeeCreate() {
onChange={setResignDate}
placeholder="Pilih tanggal keluar"
/>
<InputError message={errors.resign_date} />
<InputError
message={errors.resign_date}
/>
</div>
<div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
<Select name="employment_status" defaultValue="full_time">
<Label>
Status Kepegawaian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
name="employment_status"
defaultValue="full_time"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger>
<SelectContent>
<SelectItem value="full_time">Full Time</SelectItem>
<SelectItem value="part_time">Part Time</SelectItem>
<SelectItem value="contract">Kontrak</SelectItem>
<SelectItem value="internship">Magang</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
<SelectItem value="full_time">
Full Time
</SelectItem>
<SelectItem value="part_time">
Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">
Keluar
</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.employment_status} />
<InputError
message={
errors.employment_status
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span>
Gaji Pokok{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="base_salary" min={1} />
<InputError message={errors.base_salary} />
<RupiahInput
name="base_salary"
min={1}
/>
<InputError
message={errors.base_salary}
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>

View File

@ -45,13 +45,19 @@ type Props = {
export default function EmployeeEdit({ employee }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>(
employee.employee?.join_date ? new Date(employee.employee.join_date) : undefined
employee.employee?.join_date
? new Date(employee.employee.join_date)
: undefined,
);
const [resignDate, setResignDate] = useState<Date | undefined>(
employee.employee?.resign_date ? new Date(employee.employee.resign_date) : undefined
employee.employee?.resign_date
? new Date(employee.employee.resign_date)
: undefined,
);
const [birthDate, setBirthDate] = useState<Date | undefined>(
employee.user_profile?.birth_date ? new Date(employee.user_profile.birth_date) : undefined
employee.user_profile?.birth_date
? new Date(employee.user_profile.birth_date)
: undefined,
);
return (
@ -65,7 +71,7 @@ export default function EmployeeEdit({ employee }: Props) {
Edit Pegawai
</h2>
</div>
<Button asChild variant='outline'>
<Button asChild variant="outline">
<a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
@ -87,7 +93,10 @@ export default function EmployeeEdit({ employee }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
Email{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="email"
@ -96,11 +105,16 @@ export default function EmployeeEdit({ employee }: Props) {
placeholder="Masukkan email"
defaultValue={employee.email}
/>
<InputError message={errors.email} />
<InputError
message={errors.email}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
Username{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="username"
@ -108,7 +122,9 @@ export default function EmployeeEdit({ employee }: Props) {
placeholder="Masukkan username"
defaultValue={employee.username}
/>
<InputError message={errors.username} />
<InputError
message={errors.username}
/>
</div>
</CardContent>
</Card>
@ -120,36 +136,71 @@ export default function EmployeeEdit({ employee }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
defaultValue={employee.user_profile?.full_name ?? ''}
defaultValue={
employee.user_profile
?.full_name ?? ''
}
/>
<InputError
message={errors.full_name}
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number"/>
<InputError message={errors.phone_number} />
<PhoneNumberInput name="phone_number" />
<InputError
message={errors.phone_number}
/>
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup name="gender" defaultValue={employee.user_profile?.gender ?? ''} className="flex gap-4">
<RadioGroup
name="gender"
defaultValue={
employee.user_profile
?.gender ?? ''
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" />
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
<RadioGroupItem
value="male"
id="gender-male"
/>
<Label
htmlFor="gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" />
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
<RadioGroupItem
value="female"
id="gender-female"
/>
<Label
htmlFor="gender-female"
className="font-normal"
>
Perempuan
</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
<InputError
message={errors.gender}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
@ -159,7 +210,9 @@ export default function EmployeeEdit({ employee }: Props) {
onChange={setBirthDate}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
<InputError
message={errors.birth_date}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">
@ -170,9 +223,14 @@ export default function EmployeeEdit({ employee }: Props) {
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={employee.user_profile?.address ?? ''}
defaultValue={
employee.user_profile
?.address ?? ''
}
/>
<InputError
message={errors.address}
/>
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
@ -183,14 +241,21 @@ export default function EmployeeEdit({ employee }: Props) {
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label>
<Label>
Tanggal Masuk{' '}
<span className="text-destructive">
*
</span>
</Label>
<DatePicker
name="join_date"
value={joinDate}
onChange={setJoinDate}
placeholder="Pilih tanggal masuk"
/>
<InputError message={errors.join_date} />
<InputError
message={errors.join_date}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Keluar</Label>
@ -200,36 +265,72 @@ export default function EmployeeEdit({ employee }: Props) {
onChange={setResignDate}
placeholder="Pilih tanggal keluar"
/>
<InputError message={errors.resign_date} />
<InputError
message={errors.resign_date}
/>
</div>
<div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
<Select name="employment_status" defaultValue={employee.employee?.employment_status ?? 'full_time'}>
<Label>
Status Kepegawaian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
name="employment_status"
defaultValue={
employee.employee
?.employment_status ??
'full_time'
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger>
<SelectContent>
<SelectItem value="full_time">Full Time</SelectItem>
<SelectItem value="part_time">Part Time</SelectItem>
<SelectItem value="contract">Kontrak</SelectItem>
<SelectItem value="internship">Magang</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
<SelectItem value="full_time">
Full Time
</SelectItem>
<SelectItem value="part_time">
Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">
Keluar
</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.employment_status} />
<InputError
message={
errors.employment_status
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span>
Gaji Pokok{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="base_salary" min={1} />
<InputError message={errors.base_salary} />
<RupiahInput
name="base_salary"
min={1}
/>
<InputError
message={errors.base_salary}
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>

View File

@ -5,9 +5,26 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import type { PaginationState, SortState } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { destroy, create as employeeCreate, edit as employeeEdit, index as employeeIndex, toggleActive, resetPassword as resetPasswordRoute } from '@/routes/admin/hr/employees';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
destroy,
create as employeeCreate,
edit as employeeEdit,
index as employeeIndex,
toggleActive,
resetPassword as resetPasswordRoute,
} from '@/routes/admin/hr/employees';
import type { Employee } from './columns';
import { createEmployeeColumns } from './columns';
@ -28,10 +45,14 @@ type Props = {
export default function EmployeeIndex({ employees, filters }: Props) {
const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] =
useState<Employee | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: employees.current_page,
@ -51,71 +72,98 @@ export default function EmployeeIndex({ employees, filters }: Props) {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(employeeIndex.url(), {
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeIndex.url(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(employeeIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(employeeIndex.url(), {
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeIndex.url(),
{
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(employeeIndex.url(), {
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
employeeIndex.url(),
{
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(employeeIndex.url(), {
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort, filters]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
employeeIndex.url(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(employeeIndex.url(), {
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
employeeIndex.url(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
function handleDelete() {
@ -133,9 +181,13 @@ export default function EmployeeIndex({ employees, filters }: Props) {
return;
}
router.post(resetPasswordRoute.url(resetPasswordTarget.id), {}, {
onSuccess: () => setResetPasswordTarget(null),
});
router.post(
resetPasswordRoute.url(resetPasswordTarget.id),
{},
{
onSuccess: () => setResetPasswordTarget(null),
},
);
}
const columns = createEmployeeColumns({
@ -183,17 +235,29 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label>
<Select
value={filters.employment_status ?? 'all'}
onValueChange={(value) => applyFilter('employment_status', value)}
onValueChange={(value) =>
applyFilter('employment_status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="full_time">Full Time</SelectItem>
<SelectItem value="part_time">Part Time</SelectItem>
<SelectItem value="contract">Kontrak</SelectItem>
<SelectItem value="internship">Magang</SelectItem>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="full_time">
Full Time
</SelectItem>
<SelectItem value="part_time">
Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem>
</SelectContent>
</Select>
@ -205,7 +269,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label>
<Select
value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)}
onValueChange={(value) =>
applyFilter('is_active', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
@ -224,7 +290,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label>
<Select
value={filters.gender ?? 'all'}
onValueChange={(value) => applyFilter('gender', value)}
onValueChange={(value) =>
applyFilter('gender', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
@ -232,7 +300,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="male">Laki-laki</SelectItem>
<SelectItem value="female">Perempuan</SelectItem>
<SelectItem value="female">
Perempuan
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -7,7 +7,13 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react';
import {
ArrowUpDown,
CheckCircle,
Pencil,
Trash2,
XCircle,
} from 'lucide-react';
export type LeaveRequest = {
id: number;
@ -74,16 +80,15 @@ type CreateColumnsParams = {
export function createLeaveRequestColumns(
params: CreateColumnsParams,
): ColumnDef<LeaveRequest>[] {
const { handleEdit, handleDeleteClick, handleApprove, handleReject } = params;
const { handleEdit, handleDeleteClick, handleApprove, handleReject } =
params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -96,7 +101,11 @@ export function createLeaveRequestColumns(
cell: ({ row }) => {
const employee = row.original.employee;
return <span>{employee?.user?.user_profile?.full_name ?? '-'}</span>;
return (
<span>
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
},
},
{
@ -106,9 +115,7 @@ export function createLeaveRequestColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Tanggal Mulai</span>
@ -116,7 +123,9 @@ export function createLeaveRequestColumns(
</Button>
),
cell: ({ row }) => (
<span>{formatShortDate(row.getValue('start_date') as string)}</span>
<span>
{formatShortDate(row.getValue('start_date') as string)}
</span>
),
},
{
@ -126,9 +135,7 @@ export function createLeaveRequestColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Tanggal Selesai</span>
@ -136,7 +143,9 @@ export function createLeaveRequestColumns(
</Button>
),
cell: ({ row }) => (
<span>{formatShortDate(row.getValue('end_date') as string)}</span>
<span>
{formatShortDate(row.getValue('end_date') as string)}
</span>
),
},
{
@ -146,9 +155,7 @@ export function createLeaveRequestColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Hari</span>
@ -224,16 +231,12 @@ export function createLeaveRequestColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(leaveRequest)
}
onClick={() => handleEdit(leaveRequest)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -15,9 +15,26 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { approve, destroy, index as leaveRequestIndex, reject, store, update } from '@/routes/admin/hr/leave-requests';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
approve,
destroy,
index as leaveRequestIndex,
reject,
store,
update,
} from '@/routes/admin/hr/leave-requests';
import type { LeaveRequest } from './columns';
import { createLeaveRequestColumns } from './columns';
@ -42,11 +59,18 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
const [rejecting, setRejecting] = useState<LeaveRequest | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(undefined);
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(undefined);
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(
undefined,
);
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
undefined,
);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: leaveRequests.current_page,
@ -76,24 +100,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(leaveRequestIndex(), {
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
leaveRequestIndex(),
{
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function clearFilters() {
router.get(leaveRequestIndex(), {
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
leaveRequestIndex(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false);
}
@ -112,9 +144,13 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
return;
}
router.post(approve(approving.id), {}, {
onSuccess: () => setApproving(null),
});
router.post(
approve(approving.id),
{},
{
onSuccess: () => setApproving(null),
},
);
}
function handleReject() {
@ -122,55 +158,78 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
return;
}
router.post(reject(rejecting.id), {}, {
onSuccess: () => setRejecting(null),
});
router.post(
reject(rejecting.id),
{},
{
onSuccess: () => setRejecting(null),
},
);
}
function handlePageChange(page: number) {
router.get(leaveRequestIndex(), {
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
leaveRequestIndex(),
{
...filters,
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(leaveRequestIndex(), {
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
leaveRequestIndex(),
{
...filters,
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(leaveRequestIndex(), {
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort, filters]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
leaveRequestIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(leaveRequestIndex(), {
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
leaveRequestIndex(),
{
...filters,
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
const columns = createLeaveRequestColumns({
@ -216,17 +275,29 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
onValueChange={(value) =>
applyFilter('status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Menunggu</SelectItem>
<SelectItem value="approved">Disetujui</SelectItem>
<SelectItem value="rejected">Ditolak</SelectItem>
<SelectItem value="cancelled">Dibatalkan</SelectItem>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="pending">
Menunggu
</SelectItem>
<SelectItem value="approved">
Disetujui
</SelectItem>
<SelectItem value="rejected">
Ditolak
</SelectItem>
<SelectItem value="cancelled">
Dibatalkan
</SelectItem>
</SelectContent>
</Select>
</div>
@ -246,14 +317,17 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
Cuti
</h2>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setStartDate(undefined);
setEndDate(undefined);
}
}}>
if (!open) {
setStartDate(undefined);
setEndDate(undefined);
}
}}
>
<Button asChild>
<button
type="button"
@ -264,52 +338,97 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Permohonan Cuti</DialogTitle>
<DialogTitle>
Tambah Permohonan Cuti
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Tanggal Mulai{' '} <span className="text-destructive">*</span>
Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} />
<input
type="hidden"
name="start_date"
value={
startDate
? startDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={startDate}
onChange={setStartDate}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError message={errors.start_date} />
<InputError
message={
errors.start_date
}
/>
</div>
<div className="grid gap-2">
<Label>
Tanggal Selesai{' '} <span className="text-destructive">*</span>
Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} />
<input
type="hidden"
name="end_date"
value={
endDate
? endDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={endDate}
onChange={setEndDate}
placeholder="Pilih tanggal selesai"
min={startDate}
/>
<InputError message={errors.end_date} />
<InputError
message={
errors.end_date
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
type="submit"
disabled={processing}
>
{processing
@ -353,40 +472,91 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
setEditing(null);
setEditingStartDate(undefined);
setEditingEndDate(undefined);
}}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => {
setEditing(null);
setEditingStartDate(undefined);
setEditingEndDate(undefined);
}}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Permohonan Cuti</DialogTitle>
<DialogTitle>
Edit Permohonan Cuti
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Tanggal Mulai{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="start_date" value={editingStartDate ? editingStartDate.toISOString().split('T')[0] : ''} />
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="start_date"
value={
editingStartDate
? editingStartDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingStartDate}
onChange={setEditingStartDate}
onChange={
setEditingStartDate
}
placeholder="Pilih tanggal mulai"
min={new Date()}
/>
<InputError message={errors.start_date} />
<InputError
message={
errors.start_date
}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Selesai{' '} <span className="text-destructive">*</span></Label>
<input type="hidden" name="end_date" value={editingEndDate ? editingEndDate.toISOString().split('T')[0] : ''} />
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="end_date"
value={
editingEndDate
? editingEndDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker
value={editingEndDate}
onChange={setEditingEndDate}
onChange={
setEditingEndDate
}
placeholder="Pilih tanggal selesai"
min={editingStartDate}
/>
<InputError message={errors.end_date} />
<InputError
message={
errors.end_date
}
/>
</div>
</div>
<DialogFooter>

View File

@ -28,9 +28,7 @@ export function createCategoryColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -44,9 +42,7 @@ export function createCategoryColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama</span>
@ -77,16 +73,12 @@ export function createCategoryColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(category)
}
onClick={() => handleEdit(category)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -15,7 +15,12 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, index as categoryIndex, store, update } from '@/routes/admin/master/categories';
import {
destroy,
index as categoryIndex,
store,
update,
} from '@/routes/admin/master/categories';
import { createCategoryColumns } from './columns';
import type { Category } from './columns';
@ -34,7 +39,10 @@ export default function CategoryIndex({ categories }: Props) {
const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: categories.current_page,
@ -44,57 +52,76 @@ export default function CategoryIndex({ categories }: Props) {
};
function handlePageChange(page: number) {
router.get(categoryIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
categoryIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(categoryIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
categoryIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(categoryIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
categoryIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(categoryIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, {
preserveState: true,
replace: true,
});
router.get(
categoryIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handleDelete() {
@ -134,37 +161,49 @@ export default function CategoryIndex({ categories }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Kategori</DialogTitle>
<DialogTitle>
Tambah Kategori
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span>
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama kategori"
/>
<InputError message={errors.name} />
<InputError
message={errors.name}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
type="submit"
disabled={processing}
>
{processing
@ -205,24 +244,38 @@ export default function CategoryIndex({ categories }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Kategori</DialogTitle>
<DialogTitle>
Edit Kategori
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama kategori"
defaultValue={editing.name}
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
<InputError message={errors.name} />
</div>
</div>
<DialogFooter>

View File

@ -30,9 +30,7 @@ export function createCustomerColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createCustomerColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama</span>
@ -68,9 +64,7 @@ export function createCustomerColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>No. Telepon</span>
@ -78,9 +72,7 @@ export function createCustomerColumns(
</Button>
),
cell: ({ row }) => (
<span>
{row.getValue('phone_number') as string ?? '-'}
</span>
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
),
},
{
@ -90,9 +82,7 @@ export function createCustomerColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Alamat</span>
@ -100,8 +90,8 @@ export function createCustomerColumns(
</Button>
),
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">
{row.getValue('address') as string ?? '-'}
<span className="block max-w-[200px] truncate">
{(row.getValue('address') as string) ?? '-'}
</span>
),
},
@ -123,16 +113,12 @@ export function createCustomerColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(customer)
}
onClick={() => handleEdit(customer)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -16,7 +16,12 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as customerIndex, destroy, store, update } from '@/routes/admin/master/customers';
import {
index as customerIndex,
destroy,
store,
update,
} from '@/routes/admin/master/customers';
import type { Customer } from './columns';
import { createCustomerColumns } from './columns';
@ -35,7 +40,10 @@ export default function CustomerIndex({ customers }: Props) {
const [editing, setEditing] = useState<Customer | null>(null);
const [deleting, setDeleting] = useState<Customer | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: customers.current_page,
@ -45,57 +53,76 @@ export default function CustomerIndex({ customers }: Props) {
};
function handlePageChange(page: number) {
router.get(customerIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
customerIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(customerIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
customerIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(customerIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
customerIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(customerIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, {
preserveState: true,
replace: true,
});
router.get(
customerIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handleDelete() {
@ -135,32 +162,46 @@ export default function CustomerIndex({ customers }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Customer</DialogTitle>
<DialogTitle>
Tambah Customer
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span>
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama customer"
/>
<InputError message={errors.name} />
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number"/>
<InputError message={errors.phone_number} />
<PhoneNumberInput name="phone_number" />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="address">
@ -171,19 +212,23 @@ export default function CustomerIndex({ customers }: Props) {
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
type="submit"
disabled={processing}
>
{processing
@ -224,27 +269,43 @@ export default function CustomerIndex({ customers }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Customer</DialogTitle>
<DialogTitle>
Edit Customer
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama customer"
defaultValue={editing.name}
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">No. Telepon</Label>
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<Input
id="edit-phone_number"
name="phone_number"
@ -252,19 +313,33 @@ export default function CustomerIndex({ customers }: Props) {
inputMode="numeric"
pattern="[0-9]*"
placeholder="Masukkan nomor telepon"
defaultValue={editing.phone_number ?? ''}
defaultValue={
editing.phone_number ??
''
}
/>
<InputError
message={
errors.phone_number
}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label>
<Label htmlFor="edit-address">
Alamat
</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''}
defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/>
<InputError message={errors.address} />
</div>
</div>
<DialogFooter>

View File

@ -70,7 +70,10 @@ function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function getFilteredVariants(allVariants: ProductVariant[], searchValue: string): ProductVariant[] {
function getFilteredVariants(
allVariants: ProductVariant[],
searchValue: string,
): ProductVariant[] {
const query = searchValue.toLowerCase().trim();
return query
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
@ -93,7 +96,8 @@ export function createProductColumns(
id: 'expand',
header: '',
cell: ({ row }) => {
const hasVariants = (row.original.product_variants?.length ?? 0) > 0;
const hasVariants =
(row.original.product_variants?.length ?? 0) > 0;
if (!hasVariants) {
return null;
@ -119,7 +123,8 @@ export function createProductColumns(
},
{
id: 'variant_names',
accessorFn: (row) => row.product_variants?.map((v) => v.name).join(' ') ?? '',
accessorFn: (row) =>
row.product_variants?.map((v) => v.name).join(' ') ?? '',
header: () => null,
cell: () => null,
meta: {
@ -131,9 +136,7 @@ export function createProductColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -147,9 +150,7 @@ export function createProductColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama Produk</span>
@ -161,11 +162,11 @@ export function createProductColumns(
return (
<div className="flex flex-col">
<span className="font-medium">
{product.name}
</span>
<span className="font-medium">{product.name}</span>
<span className="text-xs text-muted-foreground">
{product.categories?.map((c) => c.name).join(', ') || '-'}
{product.categories
?.map((c) => c.name)
.join(', ') || '-'}
</span>
</div>
);
@ -175,8 +176,14 @@ export function createProductColumns(
id: 'variants',
header: () => <span>Varian</span>,
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
const searchValue =
(table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
@ -193,9 +200,18 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center',
},
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
const searchValue =
(table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalStock = variants.reduce(
(sum, v) => sum + (v.stock ?? 0),
0,
);
return (
<span className="block text-center font-medium">
@ -206,15 +222,26 @@ export function createProductColumns(
},
{
id: 'reject_stock',
header: () => <span className="block text-center">Stok Reject</span>,
header: () => (
<span className="block text-center">Stok Reject</span>
),
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0);
const searchValue =
(table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalReject = variants.reduce(
(sum, v) => sum + (v.reject_stock ?? 0),
0,
);
return (
<span className="block text-center font-medium">
@ -231,9 +258,18 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center',
},
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0);
const searchValue =
(table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
return (
<span className="block text-center font-medium">
@ -250,11 +286,26 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center',
},
cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0);
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0);
const searchValue =
(table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalStock = variants.reduce(
(sum, v) => sum + (v.stock ?? 0),
0,
);
const totalReject = variants.reduce(
(sum, v) => sum + (v.reject_stock ?? 0),
0,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
const total = totalStock + totalReject + totalRetail;
return (
@ -271,9 +322,7 @@ export function createProductColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Status</span>
@ -282,18 +331,26 @@ export function createProductColumns(
),
cell: ({ row }) => {
const product = row.original;
const isToggleable = product.status === 'active' || product.status === 'inactive';
const isToggleable =
product.status === 'active' ||
product.status === 'inactive';
const isChecked = product.status === 'active';
function handleToggle(checked: boolean) {
router.post(toggleStatusUrl(product.id), {}, {
preserveScroll: true,
});
router.post(
toggleStatusUrl(product.id),
{},
{
preserveScroll: true,
},
);
}
if (!isToggleable) {
return (
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}>
<span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
>
{getStatusLabel(product.status)}
</span>
);
@ -306,7 +363,9 @@ export function createProductColumns(
checked={isChecked}
onCheckedChange={handleToggle}
/>
<span className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}>
<span
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
>
{getStatusLabel(product.status)}
</span>
</div>
@ -336,9 +395,7 @@ export function createProductColumns(
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>
@ -346,7 +403,9 @@ export function createProductColumns(
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(product)}
onClick={() =>
handleDeleteClick(product)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>

View File

@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { index as productIndex, store } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react';
import {
ArrowLeft,
Copy,
ClipboardPaste,
Check,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
type Category = {
@ -50,7 +57,8 @@ type VariantState = {
export default function ProductCreate({ categories }: Props) {
const [categoryIds, setCategoryIds] = useState<number[]>([]);
const [useSamePrice, setUseSamePrice] = useState(true);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(createEmptyPrices());
const [sharedPrices, setSharedPrices] =
useState<Array<{ type: string; price: number }>>(createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>([
{
name: '',
@ -85,34 +93,43 @@ export default function ProductCreate({ categories }: Props) {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
}, []);
const updateVariant = useCallback(
(index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = {
...updated[variantIndex],
prices: updated[variantIndex].prices.map((p, i) =>
i === priceIndex ? { ...p, price: value } : p
),
};
return updated;
});
}, []);
const updateVariantPrice = useCallback(
(variantIndex: number, priceIndex: number, value: number) => {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = {
...updated[variantIndex],
prices: updated[variantIndex].prices.map((p, i) =>
i === priceIndex ? { ...p, price: value } : p,
),
};
return updated;
});
},
[],
);
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
setSharedPrices((prev) => {
const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated;
});
}, []);
const updateSharedPrice = useCallback(
(priceIndex: number, value: number) => {
setSharedPrices((prev) => {
const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated;
});
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
@ -129,10 +146,16 @@ export default function ProductCreate({ categories }: Props) {
const pastePrices = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => {
try {
const prices = JSON.parse(text) as Array<{ type: string; price: number }>;
const prices = JSON.parse(text) as Array<{
type: string;
price: number;
}>;
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], prices };
updated[variantIndex] = {
...updated[variantIndex],
prices,
};
return updated;
});
} catch {
@ -145,7 +168,7 @@ export default function ProductCreate({ categories }: Props) {
setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }
i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
);
});
}, []);
@ -154,10 +177,12 @@ export default function ProductCreate({ categories }: Props) {
return {
category_ids: categoryIds,
use_same_price: useSamePrice,
shared_prices: useSamePrice ? sharedPrices.map((p) => ({
type: p.type,
price: Number(p.price),
})) : [],
shared_prices: useSamePrice
? sharedPrices.map((p) => ({
type: p.type,
price: Number(p.price),
}))
: [],
variants: variantsRef.current.map((v) => ({
name: v.name,
stock: Number(v.stock),
@ -166,7 +191,10 @@ export default function ProductCreate({ categories }: Props) {
photo_key: v.photo,
prices: useSamePrice
? []
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })),
: v.prices.map((p) => ({
type: p.type,
price: Number(p.price),
})),
})),
};
}
@ -177,7 +205,9 @@ export default function ProductCreate({ categories }: Props) {
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Tambah Produk</h2>
<h2 className="text-2xl font-semibold tracking-tight">
Tambah Produk
</h2>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
@ -203,7 +233,10 @@ export default function ProductCreate({ categories }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span>
Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
@ -213,25 +246,65 @@ export default function ProductCreate({ categories }: Props) {
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label>
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
<Label>
Status{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="status"
defaultValue="active"
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" />
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
<RadioGroupItem
value="active"
id="status-active"
/>
<Label
htmlFor="status-active"
className="font-normal"
>
Aktif
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" />
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
<RadioGroupItem
value="inactive"
id="status-inactive"
/>
<Label
htmlFor="status-inactive"
className="font-normal"
>
Non Aktif
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" />
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
<RadioGroupItem
value="draft"
id="status-draft"
/>
<Label
htmlFor="status-draft"
className="font-normal"
>
Draft
</Label>
</div>
</RadioGroup>
<InputError message={errors.status} />
<InputError
message={errors.status}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label>
<Label>
Kategori{' '}
<span className="text-destructive">
*
</span>
</Label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<label
@ -242,24 +315,43 @@ export default function ProductCreate({ categories }: Props) {
type="checkbox"
name="category_ids[]"
value={category.id}
checked={categoryIds.includes(category.id)}
checked={categoryIds.includes(
category.id,
)}
onChange={(e) => {
setCategoryIds((prev) =>
e.target.checked
? [...prev, category.id]
: prev.filter((id) => id !== category.id)
setCategoryIds(
(prev) =>
e.target
.checked
? [
...prev,
category.id,
]
: prev.filter(
(
id,
) =>
id !==
category.id,
),
);
}}
className="rounded"
/>
<span>{category.name}</span>
<span>
{category.name}
</span>
</label>
))}
</div>
<InputError message={errors.category_ids} />
<InputError
message={errors.category_ids}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label>
<Label htmlFor="description">
Deskripsi
</Label>
<Textarea
id="description"
name="description"
@ -278,31 +370,80 @@ export default function ProductCreate({ categories }: Props) {
<RadioGroup
name="use_same_price"
value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')}
onValueChange={(val) =>
setUseSamePrice(val === '1')
}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" />
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
<RadioGroupItem
value="1"
id="price-same"
/>
<Label
htmlFor="price-same"
className="font-normal"
>
Semua varian sama
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" />
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
<RadioGroupItem
value="0"
id="price-different"
/>
<Label
htmlFor="price-different"
className="font-normal"
>
Harga per varian
</Label>
</div>
</RadioGroup>
{useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={sharedPrices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
/>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
</div>
))}
{PRICE_TYPES.map(
(priceType, priceIndex) => (
<div
key={priceType.key}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
sharedPrices[
priceIndex
]?.price ??
0
}
onValueChange={(
val,
) =>
updateSharedPrice(
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`shared_prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</CardContent>
@ -313,125 +454,311 @@ export default function ProductCreate({ categories }: Props) {
<CardTitle>Varian Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
{variants.map(
(variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyPrices(
variantIndex,
)
}
>
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
pastePrices(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
</>
)}
{variantIndex >
0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyPrices(variantIndex)}
size="icon"
onClick={() =>
removeVariant(
variantIndex,
)
}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.name
}
onChange={(e) =>
updateVariant(
variantIndex,
'name',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.name`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Bagus{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.reject_stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.retail_stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUpload
value={
variant.photo
}
onChange={(photo) =>
updateVariant(
variantIndex,
'photo',
photo,
)
}
folder="product-variant"
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map(
(
priceType,
priceIndex,
) => (
<div
key={
priceType.key
}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant
.prices[
priceIndex
]
?.price ??
0
}
onValueChange={(
val,
) =>
updateVariantPrice(
variantIndex,
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>Nama Varian <span className="text-destructive">*</span></Label>
<Input
value={variant.name}
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
),
)}
<Button
type="button"
variant="outline"
onClick={addVariant}
>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
@ -439,13 +766,19 @@ export default function ProductCreate({ categories }: Props) {
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing
? 'Menyimpan...'
: variants.some((v) => v.uploading)
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>

View File

@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { index as productIndex, update } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react';
import {
ArrowLeft,
Copy,
ClipboardPaste,
Check,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
type Category = {
@ -56,9 +63,14 @@ function createEmptyPrices(): Array<{ type: string; price: number }> {
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
}
function arePricesEqual(a: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>): boolean {
function arePricesEqual(
a: Array<{ type: string; price: number }>,
b: Array<{ type: string; price: number }>,
): boolean {
if (a.length !== b.length) return false;
return a.every((pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price);
return a.every(
(pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price,
);
}
type VariantState = {
@ -74,41 +86,54 @@ type VariantState = {
};
export default function ProductEdit({ product, categories }: Props) {
const initialVariants: VariantState[] = product.product_variants.map((v) => ({
id: v.id,
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}));
const initialVariants: VariantState[] = product.product_variants.map(
(v) => ({
id: v.id,
name: v.name,
stock: v.stock,
reject_stock: v.reject_stock,
retail_stock: v.retail_stock,
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}),
);
const allSamePrice = initialVariants.length > 1
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices))
: true;
const allSamePrice =
initialVariants.length > 1
? initialVariants.every((v) =>
arePricesEqual(v.prices, initialVariants[0].prices),
)
: true;
const [categoryIds, setCategoryIds] = useState<number[]>(product.category_ids);
const [categoryIds, setCategoryIds] = useState<number[]>(
product.category_ids,
);
const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices()
const [sharedPrices, setSharedPrices] = useState<
Array<{ type: string; price: number }>
>(
initialVariants.length > 0
? initialVariants[0].prices
: createEmptyPrices(),
);
const [variants, setVariants] = useState<VariantState[]>(
initialVariants.length > 0 ? initialVariants : [
{
id: null,
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photo: null,
photoUrl: null,
uploading: false,
prices: createEmptyPrices(),
},
]
initialVariants.length > 0
? initialVariants
: [
{
id: null,
name: '',
stock: 0,
reject_stock: 0,
retail_stock: 0,
photo: null,
photoUrl: null,
uploading: false,
prices: createEmptyPrices(),
},
],
);
const variantsRef = useRef(variants);
@ -135,34 +160,43 @@ export default function ProductEdit({ product, categories }: Props) {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
}, []);
const updateVariant = useCallback(
(index: number, field: keyof VariantState, value: unknown) => {
setVariants((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = {
...updated[variantIndex],
prices: updated[variantIndex].prices.map((p, i) =>
i === priceIndex ? { ...p, price: value } : p
),
};
return updated;
});
}, []);
const updateVariantPrice = useCallback(
(variantIndex: number, priceIndex: number, value: number) => {
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = {
...updated[variantIndex],
prices: updated[variantIndex].prices.map((p, i) =>
i === priceIndex ? { ...p, price: value } : p,
),
};
return updated;
});
},
[],
);
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
setSharedPrices((prev) => {
const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated;
});
}, []);
const updateSharedPrice = useCallback(
(priceIndex: number, value: number) => {
setSharedPrices((prev) => {
const updated = [...prev];
updated[priceIndex] = { ...updated[priceIndex], price: value };
return updated;
});
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
@ -179,10 +213,16 @@ export default function ProductEdit({ product, categories }: Props) {
const pastePrices = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => {
try {
const prices = JSON.parse(text) as Array<{ type: string; price: number }>;
const prices = JSON.parse(text) as Array<{
type: string;
price: number;
}>;
setVariants((prev) => {
const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], prices };
updated[variantIndex] = {
...updated[variantIndex],
prices,
};
return updated;
});
} catch {
@ -195,7 +235,7 @@ export default function ProductEdit({ product, categories }: Props) {
setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] }
i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
);
});
}, []);
@ -205,7 +245,10 @@ export default function ProductEdit({ product, categories }: Props) {
category_ids: categoryIds,
use_same_price: useSamePrice,
shared_prices: useSamePrice
? sharedPrices.map((p) => ({ type: p.type, price: Number(p.price) }))
? sharedPrices.map((p) => ({
type: p.type,
price: Number(p.price),
}))
: [],
variants: variantsRef.current.map((v) => ({
id: v.id,
@ -216,7 +259,10 @@ export default function ProductEdit({ product, categories }: Props) {
photo_key: v.photo,
prices: useSamePrice
? []
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })),
: v.prices.map((p) => ({
type: p.type,
price: Number(p.price),
})),
})),
};
}
@ -227,7 +273,9 @@ export default function ProductEdit({ product, categories }: Props) {
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Edit Produk</h2>
<h2 className="text-2xl font-semibold tracking-tight">
Edit Produk
</h2>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
@ -254,7 +302,10 @@ export default function ProductEdit({ product, categories }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span>
Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
@ -265,25 +316,65 @@ export default function ProductEdit({ product, categories }: Props) {
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label>
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4">
<Label>
Status{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="status"
defaultValue={product.status}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" />
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
<RadioGroupItem
value="active"
id="status-active"
/>
<Label
htmlFor="status-active"
className="font-normal"
>
Aktif
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" />
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
<RadioGroupItem
value="inactive"
id="status-inactive"
/>
<Label
htmlFor="status-inactive"
className="font-normal"
>
Non Aktif
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" />
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
<RadioGroupItem
value="draft"
id="status-draft"
/>
<Label
htmlFor="status-draft"
className="font-normal"
>
Draft
</Label>
</div>
</RadioGroup>
<InputError message={errors.status} />
<InputError
message={errors.status}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label>
<Label>
Kategori{' '}
<span className="text-destructive">
*
</span>
</Label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<label
@ -294,28 +385,49 @@ export default function ProductEdit({ product, categories }: Props) {
type="checkbox"
name="category_ids[]"
value={category.id}
checked={categoryIds.includes(category.id)}
checked={categoryIds.includes(
category.id,
)}
onChange={(e) => {
setCategoryIds((prev) =>
e.target.checked
? [...prev, category.id]
: prev.filter((id) => id !== category.id)
setCategoryIds(
(prev) =>
e.target
.checked
? [
...prev,
category.id,
]
: prev.filter(
(
id,
) =>
id !==
category.id,
),
);
}}
className="rounded"
/>
<span>{category.name}</span>
<span>
{category.name}
</span>
</label>
))}
</div>
<InputError message={errors.category_ids} />
<InputError
message={errors.category_ids}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label>
<Label htmlFor="description">
Deskripsi
</Label>
<Textarea
id="description"
name="description"
defaultValue={product.description ?? ''}
defaultValue={
product.description ?? ''
}
placeholder="Masukkan deskripsi produk"
rows={3}
/>
@ -331,31 +443,80 @@ export default function ProductEdit({ product, categories }: Props) {
<RadioGroup
name="use_same_price"
value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')}
onValueChange={(val) =>
setUseSamePrice(val === '1')
}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" />
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
<RadioGroupItem
value="1"
id="price-same"
/>
<Label
htmlFor="price-same"
className="font-normal"
>
Semua varian sama
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" />
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
<RadioGroupItem
value="0"
id="price-different"
/>
<Label
htmlFor="price-different"
className="font-normal"
>
Harga per varian
</Label>
</div>
</RadioGroup>
{useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={sharedPrices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
/>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
</div>
))}
{PRICE_TYPES.map(
(priceType, priceIndex) => (
<div
key={priceType.key}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
sharedPrices[
priceIndex
]?.price ??
0
}
onValueChange={(
val,
) =>
updateSharedPrice(
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`shared_prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</CardContent>
@ -366,126 +527,314 @@ export default function ProductEdit({ product, categories }: Props) {
<CardTitle>Varian Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{variants.map((variant, variantIndex) => (
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
{variants.map(
(variant, variantIndex) => (
<div
key={variantIndex}
className="space-y-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyPrices(
variantIndex,
)
}
>
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
pastePrices(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
</>
)}
{variantIndex >
0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyPrices(variantIndex)}
size="icon"
onClick={() =>
removeVariant(
variantIndex,
)
}
>
{copiedIndex === variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.name
}
onChange={(e) =>
updateVariant(
variantIndex,
'name',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.name`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Bagus{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.reject_stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.retail_stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUpload
value={
variant.photo
}
onChange={(photo) =>
updateVariant(
variantIndex,
'photo',
photo,
)
}
folder="product-variant"
existingUrl={
variant.photoUrl
}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map(
(
priceType,
priceIndex,
) => (
<div
key={
priceType.key
}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant
.prices[
priceIndex
]
?.price ??
0
}
onValueChange={(
val,
) =>
updateVariantPrice(
variantIndex,
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>Nama Varian <span className="text-destructive">*</span></Label>
<Input
value={variant.name}
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
),
)}
<Button
type="button"
variant="outline"
onClick={addVariant}
>
<Plus className="h-4 w-4" />
Tambah Varian
</Button>
@ -493,13 +842,19 @@ export default function ProductEdit({ product, categories }: Props) {
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
<div className="mt-6 flex items-center gap-4">
<Button
type="submit"
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing
? 'Menyimpan...'
: variants.some((v) => v.uploading)
? 'Mengunggah...'
: 'Simpan'}
? 'Mengunggah...'
: 'Simpan'}
</Button>
</div>
</>

View File

@ -7,9 +7,26 @@ import type { PaginationState, SortState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
@ -18,7 +35,13 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { destroy, create as productCreate, index as productIndex, edit as productEdit, toggleStatus } from '@/routes/admin/master/products';
import {
destroy,
create as productCreate,
index as productIndex,
edit as productEdit,
toggleStatus,
} from '@/routes/admin/master/products';
import type { Product } from './columns';
import { createProductColumns } from './columns';
@ -73,7 +96,13 @@ function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
);
}
function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?: string }) {
function VariantSubRow({
row,
searchValue,
}: {
row: Row<Product>;
searchValue?: string;
}) {
const allVariants = row.original.product_variants ?? [];
const query = (searchValue ?? '').toLowerCase().trim();
const variants = query
@ -95,7 +124,10 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada varian.
</TableCell>
</TableRow>
@ -114,21 +146,36 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
</div>
)}
</TableCell>
<TableCell className="font-medium">{variant.name}</TableCell>
<TableCell className="text-center">{formatNumber(variant.stock)}</TableCell>
<TableCell className="text-center">{formatNumber(variant.reject_stock)}</TableCell>
<TableCell className="text-center">{formatNumber(variant.retail_stock)}</TableCell>
<TableCell className="font-medium">
{variant.name}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.reject_stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.retail_stock)}
</TableCell>
<TableCell>
{variant.product_prices?.length > 0 ? (
<div className="flex flex-col gap-0.5">
{variant.product_prices.map((p) => (
<span key={p.id} className="text-xs">
<span className="text-muted-foreground">{p.type_label}:</span>{' '}
<span
key={p.id}
className="text-xs"
>
<span className="text-muted-foreground">
{p.type_label}:
</span>{' '}
{formatCurrency(p.price)}
</span>
))}
</div>
) : '-'}
) : (
'-'
)}
</TableCell>
</TableRow>
))
@ -142,7 +189,10 @@ export default function ProductIndex({ products, filters }: Props) {
const [deleting, setDeleting] = useState<Product | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const hasActiveFilters = filters.status || filters.name;
@ -175,57 +225,80 @@ export default function ProductIndex({ products, filters }: Props) {
}
function clearFilters() {
router.get(productIndex(), {}, {
preserveState: true,
replace: true,
});
router.get(
productIndex(),
{},
{
preserveState: true,
replace: true,
},
);
setFilterOpen(false);
}
function handlePageChange(page: number) {
router.get(productIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
...filters,
}, { preserveState: true, replace: true });
router.get(
productIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(productIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
...filters,
}, { preserveState: true, replace: true });
router.get(
productIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(productIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
...filters,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort, filters]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
productIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(productIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
...filters,
}, { preserveState: true, replace: true });
router.get(
productIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
...filters,
},
{ preserveState: true, replace: true },
);
}
function handleDelete() {
@ -282,11 +355,18 @@ export default function ProductIndex({ products, filters }: Props) {
</label>
<Combobox
value={filters.name ?? ''}
onValueChange={(value) => applyFilter('name', value as string)}
onValueChange={(value) =>
applyFilter('name', value as string)
}
>
<ComboboxInput placeholder="Pilih produk..." className="w-full" />
<ComboboxInput
placeholder="Pilih produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
<ComboboxEmpty>
Tidak ada produk ditemukan.
</ComboboxEmpty>
<ComboboxList>
{productNames.map((name) => (
<ComboboxItem key={name} value={name}>
@ -304,15 +384,21 @@ export default function ProductIndex({ products, filters }: Props) {
</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
onValueChange={(value) =>
applyFilter('status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">Non Aktif</SelectItem>
<SelectItem value="inactive">
Non Aktif
</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
@ -354,7 +440,9 @@ export default function ProductIndex({ products, filters }: Props) {
onSortChange={handleSortChange}
currentSort={sort}
searchValue={search}
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
renderSubRow={(row, searchValue) => (
<VariantSubRow row={row} searchValue={searchValue} />
)}
defaultExpanded
toolbar={filterToolbar}
/>

View File

@ -30,9 +30,7 @@ export function createSupplierColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createSupplierColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama</span>
@ -68,9 +64,7 @@ export function createSupplierColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>No. Telepon</span>
@ -78,9 +72,7 @@ export function createSupplierColumns(
</Button>
),
cell: ({ row }) => (
<span>
{row.getValue('phone_number') as string ?? '-'}
</span>
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
),
},
{
@ -90,9 +82,7 @@ export function createSupplierColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Alamat</span>
@ -100,8 +90,8 @@ export function createSupplierColumns(
</Button>
),
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">
{row.getValue('address') as string ?? '-'}
<span className="block max-w-[200px] truncate">
{(row.getValue('address') as string) ?? '-'}
</span>
),
},
@ -123,16 +113,12 @@ export function createSupplierColumns(
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(supplier)
}
onClick={() => handleEdit(supplier)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -16,7 +16,12 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, store, index as supplierIndex, update } from '@/routes/admin/master/suppliers';
import {
destroy,
store,
index as supplierIndex,
update,
} from '@/routes/admin/master/suppliers';
import type { Supplier } from './columns';
import { createSupplierColumns } from './columns';
@ -35,7 +40,10 @@ export default function SupplierIndex({ suppliers }: Props) {
const [editing, setEditing] = useState<Supplier | null>(null);
const [deleting, setDeleting] = useState<Supplier | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: suppliers.current_page,
@ -45,57 +53,76 @@ export default function SupplierIndex({ suppliers }: Props) {
};
function handlePageChange(page: number) {
router.get(supplierIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
supplierIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handlePerPageChange(perPage: number) {
router.get(supplierIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
router.get(
supplierIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(supplierIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, {
preserveState: true,
replace: true,
});
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
supplierIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(supplierIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, {
preserveState: true,
replace: true,
});
router.get(
supplierIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{
preserveState: true,
replace: true,
},
);
}
function handleDelete() {
@ -135,32 +162,46 @@ export default function SupplierIndex({ suppliers }: Props) {
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
<Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Supplier</DialogTitle>
<DialogTitle>
Tambah Supplier
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span>
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama supplier"
/>
<InputError message={errors.name} />
<InputError
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput name="phone_number"/>
<InputError message={errors.phone_number} />
<PhoneNumberInput name="phone_number" />
<InputError
message={
errors.phone_number
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="address">
@ -171,19 +212,23 @@ export default function SupplierIndex({ suppliers }: Props) {
name="address"
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
<InputError
message={errors.address}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
onClick={() =>
setCreateOpen(false)
}
>
Batal
</Button>
<Button
type='submit'
type="submit"
disabled={processing}
>
{processing
@ -224,27 +269,43 @@ export default function SupplierIndex({ suppliers }: Props) {
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
<Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Supplier</DialogTitle>
<DialogTitle>
Edit Supplier
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama supplier"
defaultValue={editing.name}
defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone_number">No. Telepon</Label>
<Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<Input
id="edit-phone_number"
name="phone_number"
@ -252,19 +313,33 @@ export default function SupplierIndex({ suppliers }: Props) {
inputMode="numeric"
pattern="[0-9]*"
placeholder="Masukkan nomor telepon"
defaultValue={editing.phone_number ?? ''}
defaultValue={
editing.phone_number ??
''
}
/>
<InputError
message={
errors.phone_number
}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label>
<Label htmlFor="edit-address">
Alamat
</Label>
<Input
id="edit-address"
name="address"
placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''}
defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/>
<InputError message={errors.address} />
</div>
</div>
<DialogFooter>

View File

@ -29,9 +29,7 @@ export function createRoleColumns(
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
<span className="block text-center">{row.index + 1}</span>
),
meta: {
className: 'w-[50px] text-center',
@ -45,9 +43,7 @@ export function createRoleColumns(
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
<span>Nama Role</span>
@ -62,7 +58,9 @@ export function createRoleColumns(
},
{
accessorKey: 'permissions_count',
header: () => <span className="block text-center">Jumlah Permission</span>,
header: () => (
<span className="block text-center">Jumlah Permission</span>
),
meta: {
className: 'w-[180px] text-center',
headerClassName: 'w-[180px] text-center',
@ -96,9 +94,7 @@ export function createRoleColumns(
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -15,41 +15,41 @@ type Props = {
};
const moduleLabels: Record<string, string> = {
'user': 'Pengguna',
'category': 'Kategori',
'supplier': 'Supplier',
'customer': 'Customer',
user: 'Pengguna',
category: 'Kategori',
supplier: 'Supplier',
customer: 'Customer',
'cash-account': 'Kas Toko',
'expense': 'Pengeluaran',
expense: 'Pengeluaran',
'employee-advance': 'Kasbon',
'payroll-period': 'Periode Gaji',
'payroll': 'Gaji',
payroll: 'Gaji',
'payroll-adjustment': 'Adjustment Gaji',
'leave-request': 'Cuti',
'attendance': 'Absensi',
'settings': 'Pengaturan',
attendance: 'Absensi',
settings: 'Pengaturan',
};
const actionLabels: Record<string, string> = {
'view': 'Lihat',
'create': 'Tambah',
'update': 'Edit',
'delete': 'Hapus',
view: 'Lihat',
create: 'Tambah',
update: 'Edit',
delete: 'Hapus',
'toggle-active': 'Aktif/Nonaktif',
'reset-password': 'Reset Kata Sandi',
'deposit': 'Setor',
'withdrawal': 'Tarik',
'approve': 'Setujui',
'pay': 'Bayar',
'reject': 'Tolak',
'current': 'Periode Saat Ini',
'close': 'Tutup',
'reopen': 'Buka Kembali',
'cancel': 'Batalkan',
deposit: 'Setor',
withdrawal: 'Tarik',
approve: 'Setujui',
pay: 'Bayar',
reject: 'Tolak',
current: 'Periode Saat Ini',
close: 'Tutup',
reopen: 'Buka Kembali',
cancel: 'Batalkan',
'check-in': 'Check In',
'check-out': 'Check Out',
'by-date': 'Lihat Per Tanggal',
'show': 'Detail',
show: 'Detail',
'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial',
@ -88,7 +88,10 @@ export default function RoleCreate({ permissions }: Props) {
<CardContent>
<div className="grid gap-2">
<Label htmlFor="name">
Nama Role <span className="text-destructive">*</span>
Nama Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
@ -105,35 +108,48 @@ export default function RoleCreate({ permissions }: Props) {
<CardTitle>Permission</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => (
<div key={module} className="grid gap-3">
<Label className="text-sm font-semibold">
{moduleLabels[module] ?? module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map((action) => (
<label
key={`${module}.${action}`}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
/>
<span className="text-xs">
{actionLabels[action] ?? action}
</span>
</label>
))}
{Object.entries(permissions).map(
([module, actions]) => (
<div
key={module}
className="grid gap-3"
>
<Label className="text-sm font-semibold">
{moduleLabels[module] ??
module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map(
(action) => (
<label
key={`${module}.${action}`}
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
/>
<span className="text-xs">
{actionLabels[
action
] ??
action}
</span>
</label>
),
)}
</div>
</div>
</div>
))}
<InputError message={errors.permissions} />
),
)}
<InputError
message={errors.permissions}
/>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>

View File

@ -25,41 +25,41 @@ type Props = {
};
const moduleLabels: Record<string, string> = {
'user': 'Pengguna',
'category': 'Kategori',
'supplier': 'Supplier',
'customer': 'Customer',
user: 'Pengguna',
category: 'Kategori',
supplier: 'Supplier',
customer: 'Customer',
'cash-account': 'Kas Toko',
'expense': 'Pengeluaran',
expense: 'Pengeluaran',
'employee-advance': 'Kasbon',
'payroll-period': 'Periode Gaji',
'payroll': 'Gaji',
payroll: 'Gaji',
'payroll-adjustment': 'Adjustment Gaji',
'leave-request': 'Cuti',
'attendance': 'Absensi',
'settings': 'Pengaturan',
attendance: 'Absensi',
settings: 'Pengaturan',
};
const actionLabels: Record<string, string> = {
'view': 'Lihat',
'create': 'Tambah',
'update': 'Edit',
'delete': 'Hapus',
view: 'Lihat',
create: 'Tambah',
update: 'Edit',
delete: 'Hapus',
'toggle-active': 'Aktif/Nonaktif',
'reset-password': 'Reset Kata Sandi',
'deposit': 'Setor',
'withdrawal': 'Tarik',
'approve': 'Setujui',
'pay': 'Bayar',
'reject': 'Tolak',
'current': 'Periode Saat Ini',
'close': 'Tutup',
'reopen': 'Buka Kembali',
'cancel': 'Batalkan',
deposit: 'Setor',
withdrawal: 'Tarik',
approve: 'Setujui',
pay: 'Bayar',
reject: 'Tolak',
current: 'Periode Saat Ini',
close: 'Tutup',
reopen: 'Buka Kembali',
cancel: 'Batalkan',
'check-in': 'Check In',
'check-out': 'Check Out',
'by-date': 'Lihat Per Tanggal',
'show': 'Detail',
show: 'Detail',
'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial',
@ -100,7 +100,10 @@ export default function RoleEdit({ role, permissions }: Props) {
<CardContent>
<div className="grid gap-2">
<Label htmlFor="name">
Nama Role <span className="text-destructive">*</span>
Nama Role{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="name"
@ -118,36 +121,51 @@ export default function RoleEdit({ role, permissions }: Props) {
<CardTitle>Permission</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => (
<div key={module} className="grid gap-3">
<Label className="text-sm font-semibold">
{moduleLabels[module] ?? module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map((action) => (
<label
key={`${module}.${action}`}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
defaultChecked={assignedPermissions.includes(`${module}.${action}`)}
/>
<span className="text-xs">
{actionLabels[action] ?? action}
</span>
</label>
))}
{Object.entries(permissions).map(
([module, actions]) => (
<div
key={module}
className="grid gap-3"
>
<Label className="text-sm font-semibold">
{moduleLabels[module] ??
module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map(
(action) => (
<label
key={`${module}.${action}`}
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
defaultChecked={assignedPermissions.includes(
`${module}.${action}`,
)}
/>
<span className="text-xs">
{actionLabels[
action
] ??
action}
</span>
</label>
),
)}
</div>
</div>
</div>
))}
<InputError message={errors.permissions} />
),
)}
<InputError
message={errors.permissions}
/>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>

View File

@ -2,12 +2,17 @@ import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import type {PaginationState, SortState} from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import type { PaginationState, SortState } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { index as rolesIndex, create as roleCreate, edit as roleEdit, destroy as roleDestroy } from '@/routes/admin/settings/roles';
import { createRoleColumns } from './columns';
import type {Role} from './columns';
import {
index as rolesIndex,
create as roleCreate,
edit as roleEdit,
destroy as roleDestroy,
} from '@/routes/admin/settings/roles';
import { createRoleColumns } from './columns';
import type { Role } from './columns';
type Props = {
roles: {
@ -22,7 +27,10 @@ type Props = {
export default function RoleIndex({ roles }: Props) {
const [deleting, setDeleting] = useState<Role | null>(null);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = {
current_page: roles.current_page,
@ -32,45 +40,64 @@ export default function RoleIndex({ roles }: Props) {
};
function handlePageChange(page: number) {
router.get(rolesIndex.url(), {
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
rolesIndex.url(),
{
page,
per_page: pagination.per_page,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
function handlePerPageChange(perPage: number) {
router.get(rolesIndex.url(), {
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
router.get(
rolesIndex.url(),
{
page: 1,
per_page: perPage,
search,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
}
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
router.get(rolesIndex.url(), {
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
}, { preserveState: true, replace: true });
}, [pagination.per_page, sort]);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
router.get(
rolesIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search: value,
sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction });
router.get(rolesIndex.url(), {
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
}, { preserveState: true, replace: true });
router.get(
rolesIndex.url(),
{
page: 1,
per_page: pagination.per_page,
search,
sort: column,
direction,
},
{ preserveState: true, replace: true },
);
}
function handleDelete() {

View File

@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { updateHomepage, updateHr, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
import {
updateHomepage,
updateHr,
updateMarketplace,
updateSocialMedia,
updateSystem,
} from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
@ -60,11 +66,17 @@ type HomepageSettingsTabProps = {
};
function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
const [heroKey, setHeroKey] = useState<string | null>(homepage.hero_image_key ?? null);
const [heroKey, setHeroKey] = useState<string | null>(
homepage.hero_image_key ?? null,
);
const [heroUploading, setHeroUploading] = useState(false);
const [aboutKey, setAboutKey] = useState<string | null>(homepage.about_image_key ?? null);
const [aboutKey, setAboutKey] = useState<string | null>(
homepage.about_image_key ?? null,
);
const [aboutUploading, setAboutUploading] = useState(false);
const [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []);
const [galleryKeys, setGalleryKeys] = useState<string[]>(
homepage.gallery_image_keys ?? [],
);
const [galleryUploading, setGalleryUploading] = useState(false);
function addGalleryImage() {
@ -76,7 +88,9 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
}
function updateGalleryKey(index: number, key: string | null) {
setGalleryKeys((prev) => prev.map((k, i) => (i === index ? (key ?? '') : k)));
setGalleryKeys((prev) =>
prev.map((k, i) => (i === index ? (key ?? '') : k)),
);
}
return (
@ -88,10 +102,23 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
<input
type="hidden"
name="hero_image_key"
value={heroKey ?? ''}
/>
<input
type="hidden"
name="about_image_key"
value={aboutKey ?? ''}
/>
{galleryKeys.map((key, index) => (
<input key={index} type="hidden" name={`gallery_image_keys[${index}]`} value={key} />
<input
key={index}
type="hidden"
name={`gallery_image_keys[${index}]`}
value={key}
/>
))}
<div className="grid gap-2">
@ -133,14 +160,28 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
</div>
<div className="grid gap-3">
{galleryKeys.map((key, index) => (
<div key={index} className="flex items-start gap-2">
<div
key={index}
className="flex items-start gap-2"
>
<div className="flex-1">
<FileUpload
value={key || null}
onChange={(k) => updateGalleryKey(index, k)}
onChange={(k) =>
updateGalleryKey(
index,
k,
)
}
folder="homepage/gallery"
existingUrl={homepage.gallery_images[index] ?? null}
onUploadingChange={setGalleryUploading}
existingUrl={
homepage.gallery_images[
index
] ?? null
}
onUploadingChange={
setGalleryUploading
}
/>
</div>
<Button
@ -148,22 +189,37 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
variant="ghost"
size="icon"
className="mt-1 text-destructive hover:text-destructive"
onClick={() => removeGalleryImage(index)}
onClick={() =>
removeGalleryImage(index)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{galleryKeys.length === 0 && (
<p className="text-sm text-muted-foreground">Belum ada gambar. Klik "Tambah" untuk menambahkan gambar galeri.</p>
<p className="text-sm text-muted-foreground">
Belum ada gambar. Klik "Tambah"
untuk menambahkan gambar galeri.
</p>
)}
</div>
<InputError message={errors.gallery_image_keys} />
<InputError
message={errors.gallery_image_keys}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing || heroUploading || aboutUploading || galleryUploading}>
<Button
type="submit"
disabled={
processing ||
heroUploading ||
aboutUploading ||
galleryUploading
}
>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
@ -183,43 +239,103 @@ const sidebarTabs = [
type TabKey = (typeof sidebarTabs)[number]['key'];
function MarketplaceVariableInput({ label, prefix, data }: { label: string; prefix: string; data: MarketplaceFeeRule }) {
function MarketplaceVariableInput({
label,
prefix,
data,
}: {
label: string;
prefix: string;
data: MarketplaceFeeRule;
}) {
const [type, setType] = useState(data.type);
return (
<div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4">
<span className="min-w-[160px] text-sm font-medium md:pt-2">{label}</span>
<span className="min-w-[160px] text-sm font-medium md:pt-2">
{label}
</span>
<div className="grid flex-1 grid-cols-1 gap-3 md:grid-cols-3">
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Dasar</span>
<RadioGroup name={`${prefix}[base]`} defaultValue={data.base} className="flex gap-4">
<span className="text-xs font-medium text-muted-foreground md:hidden">
Dasar
</span>
<RadioGroup
name={`${prefix}[base]`}
defaultValue={data.base}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="per_transaksi" id={`${prefix}-base-per_transaksi`} />
<Label htmlFor={`${prefix}-base-per_transaksi`} className="font-normal text-xs">Per Transaksi</Label>
<RadioGroupItem
value="per_transaksi"
id={`${prefix}-base-per_transaksi`}
/>
<Label
htmlFor={`${prefix}-base-per_transaksi`}
className="text-xs font-normal"
>
Per Transaksi
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="per_produk" id={`${prefix}-base-per_produk`} />
<Label htmlFor={`${prefix}-base-per_produk`} className="font-normal text-xs">Per Produk</Label>
<RadioGroupItem
value="per_produk"
id={`${prefix}-base-per_produk`}
/>
<Label
htmlFor={`${prefix}-base-per_produk`}
className="text-xs font-normal"
>
Per Produk
</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Tipe</span>
<RadioGroup name={`${prefix}[type]`} defaultValue={data.type} onValueChange={setType} className="flex gap-4">
<span className="text-xs font-medium text-muted-foreground md:hidden">
Tipe
</span>
<RadioGroup
name={`${prefix}[type]`}
defaultValue={data.type}
onValueChange={setType}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="flat" id={`${prefix}-type-flat`} />
<Label htmlFor={`${prefix}-type-flat`} className="font-normal text-xs">Flat</Label>
<RadioGroupItem
value="flat"
id={`${prefix}-type-flat`}
/>
<Label
htmlFor={`${prefix}-type-flat`}
className="text-xs font-normal"
>
Flat
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="persentase" id={`${prefix}-type-persentase`} />
<Label htmlFor={`${prefix}-type-persentase`} className="font-normal text-xs">Persentase</Label>
<RadioGroupItem
value="persentase"
id={`${prefix}-type-persentase`}
/>
<Label
htmlFor={`${prefix}-type-persentase`}
className="text-xs font-normal"
>
Persentase
</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Nilai</span>
<span className="text-xs font-medium text-muted-foreground md:hidden">
Nilai
</span>
{type === 'flat' ? (
<RupiahInput name={`${prefix}[value]`} defaultValue={data.value} />
<RupiahInput
name={`${prefix}[value]`}
defaultValue={data.value}
/>
) : (
<Input
name={`${prefix}[value]`}
@ -249,7 +365,13 @@ function MarketplaceColumnHeader() {
);
}
function MarketplaceCard({ title, variables }: { title: string; variables: { label: string; prefix: string; data: MarketplaceFeeRule }[] }) {
function MarketplaceCard({
title,
variables,
}: {
title: string;
variables: { label: string; prefix: string; data: MarketplaceFeeRule }[];
}) {
return (
<Card>
<CardHeader className="pb-3">
@ -265,29 +387,97 @@ function MarketplaceCard({ title, variables }: { title: string; variables: { lab
);
}
export default function AdminSettings({ system, homepage, socialMedia, marketplace, hr }: Props) {
export default function AdminSettings({
system,
homepage,
socialMedia,
marketplace,
hr,
}: Props) {
const [activeTab, setActiveTab] = useState<TabKey>('sistem');
const [marketplaceTab, setMarketplaceTab] = useState<'tiktok-shop' | 'shopee'>('tiktok-shop');
const [marketplaceTab, setMarketplaceTab] = useState<
'tiktok-shop' | 'shopee'
>('tiktok-shop');
const tiktokShopVariables = [
{ label: 'Komisi Platform', prefix: 'tiktok_shop_platform_commission', data: marketplace.tiktok_shop.platform_commission },
{ label: 'Layanan Logistik', prefix: 'tiktok_shop_logistics_service_fee', data: marketplace.tiktok_shop.logistics_service_fee },
{ label: 'Komisi Dinamis', prefix: 'tiktok_shop_dynamic_commission', data: marketplace.tiktok_shop.dynamic_commission },
{ label: 'Pemrosesan Pesanan', prefix: 'tiktok_shop_order_processing_fee', data: marketplace.tiktok_shop.order_processing_fee },
{ label: 'Affiliate', prefix: 'tiktok_shop_affiliate', data: marketplace.tiktok_shop.affiliate },
{ label: 'Layanan PO', prefix: 'tiktok_shop_pre_order_service_fee', data: marketplace.tiktok_shop.pre_order_service_fee },
{
label: 'Komisi Platform',
prefix: 'tiktok_shop_platform_commission',
data: marketplace.tiktok_shop.platform_commission,
},
{
label: 'Layanan Logistik',
prefix: 'tiktok_shop_logistics_service_fee',
data: marketplace.tiktok_shop.logistics_service_fee,
},
{
label: 'Komisi Dinamis',
prefix: 'tiktok_shop_dynamic_commission',
data: marketplace.tiktok_shop.dynamic_commission,
},
{
label: 'Pemrosesan Pesanan',
prefix: 'tiktok_shop_order_processing_fee',
data: marketplace.tiktok_shop.order_processing_fee,
},
{
label: 'Affiliate',
prefix: 'tiktok_shop_affiliate',
data: marketplace.tiktok_shop.affiliate,
},
{
label: 'Layanan PO',
prefix: 'tiktok_shop_pre_order_service_fee',
data: marketplace.tiktok_shop.pre_order_service_fee,
},
];
const shopeeVariables = [
{ label: 'Biaya Administrasi', prefix: 'shopee_admin_fee', data: marketplace.shopee.admin_fee },
{ label: 'Biaya Program', prefix: 'shopee_program_fee', data: marketplace.shopee.program_fee },
{ label: 'Hemat Biaya Kirim', prefix: 'shopee_shipping_savings', data: marketplace.shopee.shipping_savings },
{ label: 'Premi', prefix: 'shopee_premium', data: marketplace.shopee.premium },
{ label: 'Biaya Layanan', prefix: 'shopee_service_fee', data: marketplace.shopee.service_fee },
{ label: 'Biaya Proses Pesanan', prefix: 'shopee_order_processing_fee', data: marketplace.shopee.order_processing_fee },
{ label: 'Biaya Komisi AMS', prefix: 'shopee_ams_commission_fee', data: marketplace.shopee.ams_commission_fee },
{ label: 'PO', prefix: 'shopee_pre_order', data: marketplace.shopee.pre_order },
{ label: 'Live Extra', prefix: 'shopee_live_extra', data: marketplace.shopee.live_extra },
{
label: 'Biaya Administrasi',
prefix: 'shopee_admin_fee',
data: marketplace.shopee.admin_fee,
},
{
label: 'Biaya Program',
prefix: 'shopee_program_fee',
data: marketplace.shopee.program_fee,
},
{
label: 'Hemat Biaya Kirim',
prefix: 'shopee_shipping_savings',
data: marketplace.shopee.shipping_savings,
},
{
label: 'Premi',
prefix: 'shopee_premium',
data: marketplace.shopee.premium,
},
{
label: 'Biaya Layanan',
prefix: 'shopee_service_fee',
data: marketplace.shopee.service_fee,
},
{
label: 'Biaya Proses Pesanan',
prefix: 'shopee_order_processing_fee',
data: marketplace.shopee.order_processing_fee,
},
{
label: 'Biaya Komisi AMS',
prefix: 'shopee_ams_commission_fee',
data: marketplace.shopee.ams_commission_fee,
},
{
label: 'PO',
prefix: 'shopee_pre_order',
data: marketplace.shopee.pre_order,
},
{
label: 'Live Extra',
prefix: 'shopee_live_extra',
data: marketplace.shopee.live_extra,
},
];
return (
@ -305,7 +495,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav className="flex flex-col space-y-1 space-x-0" aria-label="Admin Settings">
<nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Admin Settings"
>
{sidebarTabs.map((tab) => (
<Button
key={tab.key}
@ -326,7 +519,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<section className="w-full min-w-0 flex-1 space-y-6">
{activeTab === 'sistem' && (
<Form action={updateSystem()} options={{ preserveScroll: true }}>
<Form
action={updateSystem()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
@ -336,35 +532,103 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="app_name">
Nama Aplikasi <span className="text-destructive">*</span>
Nama Aplikasi{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input id="app_name" name="app_name" placeholder="Masukkan nama aplikasi" defaultValue={system.app_name} />
<InputError message={errors.app_name} />
<Input
id="app_name"
name="app_name"
placeholder="Masukkan nama aplikasi"
defaultValue={
system.app_name
}
/>
<InputError
message={
errors.app_name
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="Masukkan email" defaultValue={system.email} />
<InputError message={errors.email} />
<Label htmlFor="email">
Email
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
defaultValue={
system.email
}
/>
<InputError
message={errors.email}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">No. Telepon</Label>
<PhoneNumberInput name="phone" defaultValue={system.phone} />
<InputError message={errors.phone} />
<Label htmlFor="phone">
No. Telepon
</Label>
<PhoneNumberInput
name="phone"
defaultValue={
system.phone
}
/>
<InputError
message={errors.phone}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label>
<Textarea id="address" name="address" placeholder="Masukkan alamat" rows={3} defaultValue={system.address} />
<InputError message={errors.address} />
<Label htmlFor="address">
Alamat
</Label>
<Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={
system.address
}
/>
<InputError
message={errors.address}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="about_app">Tentang Aplikasi</Label>
<Textarea id="about_app" name="about_app" placeholder="Masukkan deskripsi aplikasi" rows={4} defaultValue={system.about_app} />
<InputError message={errors.about_app} />
<Label htmlFor="about_app">
Tentang Aplikasi
</Label>
<Textarea
id="about_app"
name="about_app"
placeholder="Masukkan deskripsi aplikasi"
rows={4}
defaultValue={
system.about_app
}
/>
<InputError
message={
errors.about_app
}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div>
</div>
)}
@ -376,33 +640,87 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)}
{activeTab === 'media-sosial' && (
<Form action={updateSocialMedia()} options={{ preserveScroll: true }}>
<Form
action={updateSocialMedia()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Media Sosial</CardTitle>
<CardTitle>
Media Sosial
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="instagram_url">Instagram</Label>
<Input id="instagram_url" name="instagram_url" placeholder="https://instagram.com/..." defaultValue={socialMedia.instagram_url ?? ''} />
<InputError message={errors.instagram_url} />
<Label htmlFor="instagram_url">
Instagram
</Label>
<Input
id="instagram_url"
name="instagram_url"
placeholder="https://instagram.com/..."
defaultValue={
socialMedia.instagram_url ??
''
}
/>
<InputError
message={
errors.instagram_url
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="facebook_url">Facebook</Label>
<Input id="facebook_url" name="facebook_url" placeholder="https://facebook.com/..." defaultValue={socialMedia.facebook_url ?? ''} />
<InputError message={errors.facebook_url} />
<Label htmlFor="facebook_url">
Facebook
</Label>
<Input
id="facebook_url"
name="facebook_url"
placeholder="https://facebook.com/..."
defaultValue={
socialMedia.facebook_url ??
''
}
/>
<InputError
message={
errors.facebook_url
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="tiktok_url">TikTok</Label>
<Input id="tiktok_url" name="tiktok_url" placeholder="https://tiktok.com/..." defaultValue={socialMedia.tiktok_url ?? ''} />
<InputError message={errors.tiktok_url} />
<Label htmlFor="tiktok_url">
TikTok
</Label>
<Input
id="tiktok_url"
name="tiktok_url"
placeholder="https://tiktok.com/..."
defaultValue={
socialMedia.tiktok_url ??
''
}
/>
<InputError
message={
errors.tiktok_url
}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div>
</div>
)}
@ -410,23 +728,58 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)}
{activeTab === 'marketplace' && (
<Form action={updateMarketplace()} options={{ preserveScroll: true }}>
<Form
action={updateMarketplace()}
options={{ preserveScroll: true }}
>
{({ processing }) => (
<div className="grid gap-6">
<Tabs value={marketplaceTab} onValueChange={(v) => setMarketplaceTab(v as 'tiktok-shop' | 'shopee')}>
<Tabs
value={marketplaceTab}
onValueChange={(v) =>
setMarketplaceTab(
v as
| 'tiktok-shop'
| 'shopee',
)
}
>
<TabsList className="mb-3">
<TabsTrigger value="tiktok-shop">TikTok Shop</TabsTrigger>
<TabsTrigger value="shopee">Shopee</TabsTrigger>
<TabsTrigger value="tiktok-shop">
TikTok Shop
</TabsTrigger>
<TabsTrigger value="shopee">
Shopee
</TabsTrigger>
</TabsList>
</Tabs>
<div hidden={marketplaceTab !== 'tiktok-shop'}>
<MarketplaceCard title="TikTok Shop" variables={tiktokShopVariables} />
<div
hidden={
marketplaceTab !== 'tiktok-shop'
}
>
<MarketplaceCard
title="TikTok Shop"
variables={tiktokShopVariables}
/>
</div>
<div hidden={marketplaceTab !== 'shopee'}>
<MarketplaceCard title="Shopee" variables={shopeeVariables} />
<div
hidden={marketplaceTab !== 'shopee'}
>
<MarketplaceCard
title="Shopee"
variables={shopeeVariables}
/>
</div>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div>
</div>
)}
@ -434,7 +787,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)}
{activeTab === 'hr' && (
<Form action={updateHr()} options={{ preserveScroll: true }}>
<Form
action={updateHr()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
@ -444,36 +800,95 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="scheduled_check_in_time">
Jam Masuk Kerja <span className="text-destructive">*</span>
Jam Masuk Kerja{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input id="scheduled_check_in_time" name="scheduled_check_in_time" type="time" defaultValue={hr.scheduled_check_in_time} />
<InputError message={errors.scheduled_check_in_time} />
<Input
id="scheduled_check_in_time"
name="scheduled_check_in_time"
type="time"
defaultValue={
hr.scheduled_check_in_time
}
/>
<InputError
message={
errors.scheduled_check_in_time
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="scheduled_check_out_time">
Jam Pulang Kerja <span className="text-destructive">*</span>
Jam Pulang Kerja{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input id="scheduled_check_out_time" name="scheduled_check_out_time" type="time" defaultValue={hr.scheduled_check_out_time} />
<InputError message={errors.scheduled_check_out_time} />
<Input
id="scheduled_check_out_time"
name="scheduled_check_out_time"
type="time"
defaultValue={
hr.scheduled_check_out_time
}
/>
<InputError
message={
errors.scheduled_check_out_time
}
/>
</div>
<div className="grid gap-2">
<Label>
Denda Keterlambatan <span className="text-destructive">*</span>
Denda Keterlambatan{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="late_penalty_amount" defaultValue={hr.late_penalty_amount} />
<InputError message={errors.late_penalty_amount} />
<RupiahInput
name="late_penalty_amount"
defaultValue={
hr.late_penalty_amount
}
/>
<InputError
message={
errors.late_penalty_amount
}
/>
</div>
<div className="grid gap-2">
<Label>
Denda Bolos <span className="text-destructive">*</span>
Denda Bolos{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput name="absent_penalty_amount" defaultValue={hr.absent_penalty_amount} />
<InputError message={errors.absent_penalty_amount} />
<RupiahInput
name="absent_penalty_amount"
defaultValue={
hr.absent_penalty_amount
}
/>
<InputError
message={
errors.absent_penalty_amount
}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div>
</div>
)}

View File

@ -14,7 +14,7 @@ export default function Login() {
<Head title="Masuk Akun" />
<Form
action={store()}
action={store()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
>
@ -43,7 +43,9 @@ export default function Login() {
<div className="flex items-center">
<Label htmlFor="password">
Kata Sandi
<span className="text-destructive">*</span>
<span className="text-destructive">
*
</span>
</Label>
</div>
<PasswordInput

View File

@ -8,7 +8,10 @@ import { useEffect, useState } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
const isPWA = () => {
return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as { standalone?: boolean }).standalone === true;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as { standalone?: boolean }).standalone === true
);
};
const getPlatform = (): 'android' | 'ios' | 'desktop' => {
@ -36,7 +39,8 @@ const getDeniedMessage = (appName: string): string => {
};
export default function Permissions() {
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
const [notifications, setNotifications] =
useState<PermissionStatus>('unknown');
const [camera, setCamera] = useState<PermissionStatus>('unknown');
const [location, setLocation] = useState<PermissionStatus>('unknown');
@ -44,24 +48,32 @@ export default function Permissions() {
useEffect(() => {
if ('Notification' in window) {
if (Notification.permission === 'granted') setNotifications('granted');
else if (Notification.permission === 'denied') setNotifications('denied');
if (Notification.permission === 'granted')
setNotifications('granted');
else if (Notification.permission === 'denied')
setNotifications('denied');
else setNotifications('prompt');
}
if (navigator.mediaDevices) {
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
}).catch(() => {
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'camera' as PermissionName }).then((result) => {
setCamera(result.state as PermissionStatus);
}).catch(() => setCamera('denied'));
} else {
setCamera('denied');
}
});
navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
})
.catch(() => {
if ('permissions' in navigator) {
navigator.permissions
.query({ name: 'camera' as PermissionName })
.then((result) => {
setCamera(result.state as PermissionStatus);
})
.catch(() => setCamera('denied'));
} else {
setCamera('denied');
}
});
} else {
setCamera('denied');
}
@ -71,9 +83,12 @@ export default function Permissions() {
() => setLocation('granted'),
() => {
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'geolocation' }).then((result) => {
setLocation(result.state as PermissionStatus);
}).catch(() => setLocation('denied'));
navigator.permissions
.query({ name: 'geolocation' })
.then((result) => {
setLocation(result.state as PermissionStatus);
})
.catch(() => setLocation('denied'));
} else {
setLocation('denied');
}
@ -92,7 +107,13 @@ export default function Permissions() {
return;
}
const result = await Notification.requestPermission();
setNotifications(result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'prompt');
setNotifications(
result === 'granted'
? 'granted'
: result === 'denied'
? 'denied'
: 'prompt',
);
} else {
setNotifications('prompt');
}
@ -101,7 +122,9 @@ export default function Permissions() {
const handleCamera = async (checked: boolean) => {
if (checked) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
} catch {
@ -133,13 +156,18 @@ export default function Permissions() {
<h1 className="sr-only">Izin</h1>
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto px-4 md:px-6">
<Alert variant={isDenied(notifications) ? 'destructive' : 'default'}>
<Alert
variant={
isDenied(notifications) ? 'destructive' : 'default'
}
>
<Bell className="h-4 w-4" />
<div className="flex items-center justify-between">
<div className="flex-1">
<AlertTitle>Notifikasi</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda.
Izinkan aplikasi mengirimkan notifikasi push ke
perangkat Anda.
</AlertDescription>
{isDenied(notifications) && (
<p className="mt-1 text-sm text-destructive">
@ -163,7 +191,8 @@ export default function Permissions() {
<div className="flex-1">
<AlertTitle>Kamera</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video.
Izinkan aplikasi mengakses kamera perangkat Anda
untuk mengambil foto atau video.
</AlertDescription>
{isDenied(camera) && (
<p className="mt-1 text-sm text-destructive">
@ -187,7 +216,8 @@ export default function Permissions() {
<div className="flex-1">
<AlertTitle>Lokasi</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi.
Izinkan aplikasi mengakses lokasi perangkat Anda
untuk menyediakan layanan berbasis lokasi.
</AlertDescription>
{isDenied(location) && (
<p className="mt-1 text-sm text-destructive">

View File

@ -33,7 +33,9 @@ type Props = {
export default function Profile({ user }: Props) {
const [birthDate, setBirthDate] = useState<Date | undefined>(
user.userProfile?.birth_date ? new Date(user.userProfile.birth_date) : undefined
user.userProfile?.birth_date
? new Date(user.userProfile.birth_date)
: undefined,
);
return (
@ -56,7 +58,10 @@ export default function Profile({ user }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
Email{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="email"
@ -69,7 +74,10 @@ export default function Profile({ user }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
Username{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="username"
@ -89,38 +97,71 @@ export default function Profile({ user }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
defaultValue={user.userProfile?.full_name ?? ''}
defaultValue={
user.userProfile?.full_name ??
''
}
/>
<InputError
message={errors.full_name}
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">No. Telepon</Label>
<Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput
name="phone_number"
defaultValue={user.userProfile?.phone_number ?? ''}
defaultValue={
user.userProfile
?.phone_number ?? ''
}
/>
<InputError
message={errors.phone_number}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup
name="gender"
defaultValue={user.userProfile?.gender ?? ''}
defaultValue={
user.userProfile?.gender ?? ''
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="settings-gender-male" />
<Label htmlFor="settings-gender-male" className="font-normal">Laki-laki</Label>
<RadioGroupItem
value="male"
id="settings-gender-male"
/>
<Label
htmlFor="settings-gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="settings-gender-female" />
<Label htmlFor="settings-gender-female" className="font-normal">Perempuan</Label>
<RadioGroupItem
value="female"
id="settings-gender-female"
/>
<Label
htmlFor="settings-gender-female"
className="font-normal"
>
Perempuan
</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
@ -133,7 +174,9 @@ export default function Profile({ user }: Props) {
onChange={setBirthDate}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
<InputError
message={errors.birth_date}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label>
@ -142,7 +185,9 @@ export default function Profile({ user }: Props) {
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={user.userProfile?.address ?? ''}
defaultValue={
user.userProfile?.address ?? ''
}
/>
<InputError message={errors.address} />
</div>

View File

@ -45,7 +45,10 @@ export default function Security(props: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="current_password">
Kata Sandi Saat <span className="text-destructive">*</span>
Kata Sandi Saat{' '}
<span className="text-destructive">
*
</span>
</Label>
<PasswordInput
@ -57,12 +60,17 @@ export default function Security(props: Props) {
placeholder="Masukkan kata sandi saat ini"
/>
<InputError message={errors.current_password} />
<InputError
message={errors.current_password}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">
Kata Sandi Baru <span className="text-destructive">*</span>
Kata Sandi Baru{' '}
<span className="text-destructive">
*
</span>
</Label>
<PasswordInput
@ -80,7 +88,10 @@ export default function Security(props: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Konfirmasi Kata Sandi <span className="text-destructive">*</span>
Konfirmasi Kata Sandi{' '}
<span className="text-destructive">
*
</span>
</Label>
<PasswordInput
@ -93,7 +104,9 @@ export default function Security(props: Props) {
/>
<InputError
message={errors.password_confirmation}
message={
errors.password_confirmation
}
/>
</div>
</CardContent>

View File

@ -5,9 +5,14 @@ declare module 'virtual:pwa-register' {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
onRegisteredSW?: (swUrl: string, registration: ServiceWorkerRegistration | undefined) => void;
onRegisteredSW?: (
swUrl: string,
registration: ServiceWorkerRegistration | undefined,
) => void;
onRegisterError?: (error: Error) => void;
}
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => void;
export function registerSW(
options?: RegisterSWOptions,
): (reloadPage?: boolean) => void;
}

View File

@ -1,6 +1,5 @@
<?php
use App\Enums\PriceType;
use App\Models\Category;
use App\Models\Product;
use App\Models\ProductPrice;
@ -387,7 +386,7 @@ function allPriceTypes(): array
'reject_stock' => 2,
'retail_stock' => 4,
'photo_key' => 'product-variant/v2.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()),
'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()),
],
],
]));
@ -409,7 +408,7 @@ function allPriceTypes(): array
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/free.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()),
'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()),
]],
]));
@ -431,7 +430,7 @@ function allPriceTypes(): array
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/expensive.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()),
'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()),
]],
]));
@ -1654,7 +1653,7 @@ function allPriceTypes(): array
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '15.000'], allPriceTypes()),
'shared_prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => '15.000'], allPriceTypes()),
]));
$response->assertSessionHasErrors('shared_prices.0.price');
@ -1665,7 +1664,7 @@ function allPriceTypes(): array
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '1.250.000'], allPriceTypes()),
'shared_prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => '1.250.000'], allPriceTypes()),
]));
$response->assertSessionHasErrors('shared_prices.0.price');