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:
parent
2a3e70b78d
commit
23cc327190
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Finance;
|
namespace App\Http\Controllers\Admin\Finance;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
|
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\CashTransaction;
|
use App\Models\CashTransaction;
|
||||||
use App\Services\Admin\Finance\CashAccountService;
|
use App\Services\Admin\Finance\CashAccountService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Finance;
|
namespace App\Http\Controllers\Admin\Finance;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
|
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
use App\Services\Admin\Finance\EmployeeAdvanceService;
|
use App\Services\Admin\Finance\EmployeeAdvanceService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Finance;
|
namespace App\Http\Controllers\Admin\Finance;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Finance\ExpenseRequest;
|
use App\Http\Requests\Admin\Finance\ExpenseRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Expense;
|
use App\Models\Expense;
|
||||||
use App\Services\Admin\Finance\ExpenseService;
|
use App\Services\Admin\Finance\ExpenseService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -16,7 +16,7 @@ public function __construct(
|
|||||||
public function pay(Payroll $payroll): RedirectResponse
|
public function pay(Payroll $payroll): RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->handleAction(
|
return $this->handleAction(
|
||||||
fn() => $this->service->pay($payroll),
|
fn () => $this->service->pay($payroll),
|
||||||
'Gaji berhasil dibayar.',
|
'Gaji berhasil dibayar.',
|
||||||
'admin.finance.payroll-periods.show',
|
'admin.finance.payroll-periods.show',
|
||||||
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
||||||
@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse
|
|||||||
public function cancel(Payroll $payroll): RedirectResponse
|
public function cancel(Payroll $payroll): RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->handleAction(
|
return $this->handleAction(
|
||||||
fn() => $this->service->cancel($payroll),
|
fn () => $this->service->cancel($payroll),
|
||||||
'Gaji berhasil dibatalkan.',
|
'Gaji berhasil dibatalkan.',
|
||||||
'admin.finance.payroll-periods.show',
|
'admin.finance.payroll-periods.show',
|
||||||
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\HR;
|
namespace App\Http\Controllers\Admin\HR;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\HR\EmployeeRequest;
|
use App\Http\Requests\Admin\HR\EmployeeRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Admin\HR\EmployeeService;
|
use App\Services\Admin\HR\EmployeeService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\HR;
|
namespace App\Http\Controllers\Admin\HR;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\HR\LeaveRequestRequest;
|
use App\Http\Requests\Admin\HR\LeaveRequestRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\LeaveRequest;
|
use App\Models\LeaveRequest;
|
||||||
use App\Services\Admin\HR\LeaveRequestService;
|
use App\Services\Admin\HR\LeaveRequestService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Master\CategoryRequest;
|
use App\Http\Requests\Admin\Master\CategoryRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Services\Admin\Master\CategoryService;
|
use App\Services\Admin\Master\CategoryService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Master\CustomerRequest;
|
use App\Http\Requests\Admin\Master\CustomerRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Services\Admin\Master\CustomerService;
|
use App\Services\Admin\Master\CustomerService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\ProductStatus;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Master\ProductRequest;
|
use App\Http\Requests\Admin\Master\ProductRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Services\Admin\Master\CategoryService;
|
use App\Services\Admin\Master\CategoryService;
|
||||||
use App\Services\Admin\Master\ProductService;
|
use App\Services\Admin\Master\ProductService;
|
||||||
@ -41,7 +40,7 @@ public function create(): Response
|
|||||||
public function store(ProductRequest $request): RedirectResponse
|
public function store(ProductRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->handleAction(
|
return $this->handleAction(
|
||||||
fn() => $this->service->create($request->validated()),
|
fn () => $this->service->create($request->validated()),
|
||||||
'Produk berhasil ditambahkan.',
|
'Produk berhasil ditambahkan.',
|
||||||
'admin.master.products.index',
|
'admin.master.products.index',
|
||||||
'admin.master.products.create'
|
'admin.master.products.create'
|
||||||
@ -59,7 +58,7 @@ public function edit(Product $product): Response
|
|||||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->handleAction(
|
return $this->handleAction(
|
||||||
fn() => $this->service->update($product, $request->validated()),
|
fn () => $this->service->update($product, $request->validated()),
|
||||||
'Produk berhasil diperbarui.',
|
'Produk berhasil diperbarui.',
|
||||||
'admin.master.products.index',
|
'admin.master.products.index',
|
||||||
'admin.master.products.edit',
|
'admin.master.products.edit',
|
||||||
@ -70,7 +69,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
|
|||||||
public function destroy(Product $product): RedirectResponse
|
public function destroy(Product $product): RedirectResponse
|
||||||
{
|
{
|
||||||
return $this->handleAction(
|
return $this->handleAction(
|
||||||
fn() => $this->service->delete($product),
|
fn () => $this->service->delete($product),
|
||||||
'Produk berhasil dihapus.',
|
'Produk berhasil dihapus.',
|
||||||
'admin.master.products.index'
|
'admin.master.products.index'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\Master\SupplierRequest;
|
use App\Http\Requests\Admin\Master\SupplierRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Supplier;
|
use App\Models\Supplier;
|
||||||
use App\Services\Admin\Master\SupplierService;
|
use App\Services\Admin\Master\SupplierService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
|
||||||
use App\Http\Requests\Admin\RoleRequest;
|
use App\Http\Requests\Admin\RoleRequest;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Services\Admin\Settings\RoleService;
|
use App\Services\Admin\Settings\RoleService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|||||||
@ -13,13 +13,16 @@ protected function handleAction(callable $action, string $successMessage, string
|
|||||||
try {
|
try {
|
||||||
$action();
|
$action();
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
|
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
|
||||||
|
|
||||||
return to_route($redirectRoute, $parameters);
|
return to_route($redirectRoute, $parameters);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$firstError = collect($e->errors())->flatten()->first();
|
$firstError = collect($e->errors())->flatten()->first();
|
||||||
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
|
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
|
||||||
|
|
||||||
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
|
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
|
||||||
|
|
||||||
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,7 @@ public function rules(): array
|
|||||||
'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
|
'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
|
||||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||||
'variants.*.photo_key' => ['required', 'string', 'max:500'],
|
'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.*.id' => ['nullable', 'integer'],
|
||||||
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())],
|
'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'],
|
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'],
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['type_label'])]
|
#[Appends(['type_label'])]
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class ProductVariant extends Model implements HasMedia
|
class ProductVariant extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes, InteractsWithMedia;
|
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
public function orderItems(): HasMany
|
public function orderItems(): HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Master;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
|
||||||
use App\Models\ProductPrice;
|
use App\Models\ProductPrice;
|
||||||
|
use App\Models\ProductVariant;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@ -123,6 +122,7 @@ public function getForEdit(Product $product): array
|
|||||||
|
|
||||||
$variants = $product->productVariants->map(function (ProductVariant $variant) {
|
$variants = $product->productVariants->map(function (ProductVariant $variant) {
|
||||||
$media = $variant->getMedia('photos')->first();
|
$media = $variant->getMedia('photos')->first();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'name' => $variant->name,
|
'name' => $variant->name,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
@import "shadcn/tailwind.css";
|
@import 'shadcn/tailwind.css';
|
||||||
@import "@fontsource-variable/inter";
|
@import '@fontsource-variable/inter';
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@ -65,137 +65,74 @@ @theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background:
|
--background: oklch(1 0 0);
|
||||||
oklch(1 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--foreground:
|
--card: oklch(1 0 0);
|
||||||
oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--card:
|
--popover: oklch(1 0 0);
|
||||||
oklch(1 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--card-foreground:
|
--primary: oklch(0.555 0.163 48.998);
|
||||||
oklch(0.145 0 0);
|
--primary-foreground: oklch(0.987 0.022 95.277);
|
||||||
--popover:
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
oklch(1 0 0);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground:
|
--muted: oklch(0.97 0 0);
|
||||||
oklch(0.145 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--primary:
|
--accent: oklch(0.97 0 0);
|
||||||
oklch(0.555 0.163 48.998);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--primary-foreground:
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
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);
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
--border:
|
--border: oklch(0.922 0 0);
|
||||||
oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--input:
|
--ring: oklch(0.708 0 0);
|
||||||
oklch(0.922 0 0);
|
--chart-1: oklch(0.879 0.169 91.605);
|
||||||
--ring:
|
--chart-2: oklch(0.769 0.188 70.08);
|
||||||
oklch(0.708 0 0);
|
--chart-3: oklch(0.666 0.179 58.318);
|
||||||
--chart-1:
|
--chart-4: oklch(0.555 0.163 48.998);
|
||||||
oklch(0.879 0.169 91.605);
|
--chart-5: oklch(0.473 0.137 46.201);
|
||||||
--chart-2:
|
--radius: 0.625rem;
|
||||||
oklch(0.769 0.188 70.08);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--chart-3:
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
oklch(0.666 0.179 58.318);
|
--sidebar-primary: oklch(0.666 0.179 58.318);
|
||||||
--chart-4:
|
--sidebar-primary-foreground: oklch(0.987 0.022 95.277);
|
||||||
oklch(0.555 0.163 48.998);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--chart-5:
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
oklch(0.473 0.137 46.201);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--radius:
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
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 {
|
.dark {
|
||||||
--background:
|
--background: oklch(0.145 0 0);
|
||||||
oklch(0.145 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--foreground:
|
--card: oklch(0.205 0 0);
|
||||||
oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--card:
|
--popover: oklch(0.205 0 0);
|
||||||
oklch(0.205 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--card-foreground:
|
--primary: oklch(0.473 0.137 46.201);
|
||||||
oklch(0.985 0 0);
|
--primary-foreground: oklch(0.987 0.022 95.277);
|
||||||
--popover:
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--popover-foreground:
|
--muted: oklch(0.269 0 0);
|
||||||
oklch(0.985 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--primary:
|
--accent: oklch(0.269 0 0);
|
||||||
oklch(0.473 0.137 46.201);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--primary-foreground:
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
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);
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
--border:
|
--border: oklch(1 0 0 / 10%);
|
||||||
oklch(1 0 0 / 10%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--input:
|
--ring: oklch(0.556 0 0);
|
||||||
oklch(1 0 0 / 15%);
|
--chart-1: oklch(0.879 0.169 91.605);
|
||||||
--ring:
|
--chart-2: oklch(0.769 0.188 70.08);
|
||||||
oklch(0.556 0 0);
|
--chart-3: oklch(0.666 0.179 58.318);
|
||||||
--chart-1:
|
--chart-4: oklch(0.555 0.163 48.998);
|
||||||
oklch(0.879 0.169 91.605);
|
--chart-5: oklch(0.473 0.137 46.201);
|
||||||
--chart-2:
|
--sidebar: oklch(0.205 0 0);
|
||||||
oklch(0.769 0.188 70.08);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--chart-3:
|
--sidebar-primary: oklch(0.769 0.188 70.08);
|
||||||
oklch(0.666 0.179 58.318);
|
--sidebar-primary-foreground: oklch(0.279 0.077 45.635);
|
||||||
--chart-4:
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
oklch(0.555 0.163 48.998);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--chart-5:
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
oklch(0.473 0.137 46.201);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
--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 {
|
@layer base {
|
||||||
@ -206,90 +143,50 @@ @layer base {
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading:
|
--font-heading: var(--font-sans);
|
||||||
var(--font-sans);
|
--font-sans: 'Inter Variable', sans-serif;
|
||||||
--font-sans:
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
'Inter Variable', sans-serif;
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring:
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
var(--sidebar-ring);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-border:
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
var(--sidebar-border);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-accent-foreground:
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
var(--sidebar-accent-foreground);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-accent:
|
--color-chart-5: var(--chart-5);
|
||||||
var(--sidebar-accent);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-sidebar-primary-foreground:
|
--color-chart-3: var(--chart-3);
|
||||||
var(--sidebar-primary-foreground);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-sidebar-primary:
|
--color-chart-1: var(--chart-1);
|
||||||
var(--sidebar-primary);
|
--color-ring: var(--ring);
|
||||||
--color-sidebar-foreground:
|
--color-input: var(--input);
|
||||||
var(--sidebar-foreground);
|
--color-border: var(--border);
|
||||||
--color-sidebar:
|
--color-destructive: var(--destructive);
|
||||||
var(--sidebar);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-chart-5:
|
--color-accent: var(--accent);
|
||||||
var(--chart-5);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-chart-4:
|
--color-muted: var(--muted);
|
||||||
var(--chart-4);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-chart-3:
|
--color-secondary: var(--secondary);
|
||||||
var(--chart-3);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-chart-2:
|
--color-primary: var(--primary);
|
||||||
var(--chart-2);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-chart-1:
|
--color-popover: var(--popover);
|
||||||
var(--chart-1);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-ring:
|
--color-card: var(--card);
|
||||||
var(--ring);
|
--color-foreground: var(--foreground);
|
||||||
--color-input:
|
--color-background: var(--background);
|
||||||
var(--input);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--color-border:
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
var(--border);
|
--radius-lg: var(--radius);
|
||||||
--color-destructive:
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
var(--destructive);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--color-accent-foreground:
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
var(--accent-foreground);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type { ImgHTMLAttributes } from 'react';
|
|||||||
|
|
||||||
import logo from '../../../public/assets/logo.png';
|
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} />;
|
return <img src={logo} alt="Logo" {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,10 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild tooltip={{ children: item.title }}>
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip={{ children: item.title }}
|
||||||
|
>
|
||||||
<Link href={item.href} prefetch>
|
<Link href={item.href} prefetch>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
@ -151,7 +154,11 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild isActive={isCurrentUrl('/dashboard')} tooltip={{ children: dasborItem.title }}>
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={isCurrentUrl('/dashboard')}
|
||||||
|
tooltip={{ children: dasborItem.title }}
|
||||||
|
>
|
||||||
<Link href={dasborItem.href} prefetch>
|
<Link href={dasborItem.href} prefetch>
|
||||||
<dasborItem.icon />
|
<dasborItem.icon />
|
||||||
<span>{dasborItem.title}</span>
|
<span>{dasborItem.title}</span>
|
||||||
@ -164,7 +171,10 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip={{ children: analisaItem.title }}>
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip={{ children: analisaItem.title }}
|
||||||
|
>
|
||||||
<Link href={analisaItem.href} prefetch>
|
<Link href={analisaItem.href} prefetch>
|
||||||
<analisaItem.icon />
|
<analisaItem.icon />
|
||||||
<span>{analisaItem.title}</span>
|
<span>{analisaItem.title}</span>
|
||||||
|
|||||||
@ -25,7 +25,9 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch {
|
} 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 ? (
|
{error ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
<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>
|
<Button onClick={startCamera}>Coba Lagi</Button>
|
||||||
</div>
|
</div>
|
||||||
) : capturedImage ? (
|
) : capturedImage ? (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<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" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
Ulangi
|
Ulangi
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@ -17,9 +17,15 @@ import {
|
|||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getExpandedRowModel,
|
getExpandedRowModel,
|
||||||
useReactTable
|
useReactTable,
|
||||||
} from '@tanstack/react-table';
|
} 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 * as React from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -167,8 +173,8 @@ export function DataTable<TData, TValue>({
|
|||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
||||||
if (!defaultExpanded || !data.length) {
|
if (!defaultExpanded || !data.length) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial: Record<string, boolean> = {};
|
const initial: Record<string, boolean> = {};
|
||||||
data.forEach((item, index) => {
|
data.forEach((item, index) => {
|
||||||
@ -194,17 +200,17 @@ return {};
|
|||||||
|
|
||||||
const visibleColumns = isSortable
|
const visibleColumns = isSortable
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'drag',
|
id: 'drag',
|
||||||
header: '',
|
header: '',
|
||||||
cell: () => <DragHandleTrigger />,
|
cell: () => <DragHandleTrigger />,
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[40px]',
|
className: 'w-[40px]',
|
||||||
headerClassName: 'w-[40px]',
|
headerClassName: 'w-[40px]',
|
||||||
},
|
},
|
||||||
} as ColumnDef<TData, TValue>,
|
} as ColumnDef<TData, TValue>,
|
||||||
...columns,
|
...columns,
|
||||||
]
|
]
|
||||||
: columns;
|
: columns;
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@ -261,8 +267,8 @@ return {};
|
|||||||
|
|
||||||
function handleSort(columnId: string) {
|
function handleSort(columnId: string) {
|
||||||
if (!onSortChange) {
|
if (!onSortChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDirection =
|
const newDirection =
|
||||||
currentSort?.column === columnId && currentSort?.direction === 'asc'
|
currentSort?.column === columnId && currentSort?.direction === 'asc'
|
||||||
@ -282,25 +288,31 @@ return;
|
|||||||
<Input
|
<Input
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={localSearch}
|
value={localSearch}
|
||||||
onChange={(event) => handleSearchChange(event.target.value)}
|
onChange={(event) =>
|
||||||
|
handleSearchChange(event.target.value)
|
||||||
|
}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{toolbar}
|
{toolbar}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isServerMode && onPerPageChange && (
|
{isServerMode && onPerPageChange && (
|
||||||
<Select
|
<Select
|
||||||
value={String(pagination?.per_page ?? 25)}
|
value={String(pagination?.per_page ?? 25)}
|
||||||
onValueChange={(value) => onPerPageChange(Number(value))}
|
onValueChange={(value) =>
|
||||||
|
onPerPageChange(Number(value))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="25">25</SelectItem>
|
<SelectItem value="25">25</SelectItem>
|
||||||
<SelectItem value="50">50</SelectItem>
|
<SelectItem value="50">50</SelectItem>
|
||||||
<SelectItem value="100">100</SelectItem>
|
<SelectItem value="100">100</SelectItem>
|
||||||
<SelectItem value="999999">Semua</SelectItem>
|
<SelectItem value="999999">
|
||||||
|
Semua
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
@ -320,18 +332,18 @@ return;
|
|||||||
(
|
(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.meta as {
|
.meta as {
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
}
|
}
|
||||||
)?.headerClassName
|
)?.headerClassName
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -374,8 +386,8 @@ return;
|
|||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.meta as {
|
.meta as {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
?.className
|
?.className
|
||||||
}
|
}
|
||||||
@ -394,55 +406,58 @@ return;
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
table
|
table.getRowModel().rows.map((row) => (
|
||||||
.getRowModel()
|
<React.Fragment key={row.id}>
|
||||||
.rows.map((row) => (
|
<TableRow
|
||||||
<React.Fragment key={row.id}>
|
data-state={
|
||||||
<TableRow
|
row.getIsSelected() &&
|
||||||
data-state={
|
'selected'
|
||||||
row.getIsSelected() &&
|
}
|
||||||
'selected'
|
>
|
||||||
}
|
{row
|
||||||
>
|
.getVisibleCells()
|
||||||
{row
|
.map((cell) => (
|
||||||
.getVisibleCells()
|
<TableCell
|
||||||
.map((cell) => (
|
key={cell.id}
|
||||||
<TableCell
|
className={
|
||||||
key={cell.id}
|
(
|
||||||
className={
|
|
||||||
(
|
|
||||||
cell
|
|
||||||
.column
|
|
||||||
.columnDef
|
|
||||||
.meta as {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
)?.className
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
cell.column
|
cell.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.cell,
|
.meta as {
|
||||||
cell.getContext(),
|
className?: string;
|
||||||
)}
|
}
|
||||||
</TableCell>
|
)?.className
|
||||||
))}
|
}
|
||||||
</TableRow>
|
>
|
||||||
{renderSubRow && row.getIsExpanded() && (
|
{flexRender(
|
||||||
|
cell.column
|
||||||
|
.columnDef
|
||||||
|
.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
{renderSubRow &&
|
||||||
|
row.getIsExpanded() && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={visibleColumns.length}
|
colSpan={
|
||||||
|
visibleColumns.length
|
||||||
|
}
|
||||||
className="bg-muted/50 p-0"
|
className="bg-muted/50 p-0"
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{renderSubRow(row, localSearch)}
|
{renderSubRow(
|
||||||
|
row,
|
||||||
|
localSearch,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@ -530,7 +545,9 @@ return;
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
onClick={() =>
|
||||||
|
table.setPageIndex(table.getPageCount() - 1)
|
||||||
|
}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<ChevronsRight className="h-4 w-4" />
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
|||||||
@ -1,33 +1,33 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { format } from "date-fns"
|
import { format } from 'date-fns';
|
||||||
import { id } from "date-fns/locale"
|
import { id } from 'date-fns/locale';
|
||||||
import { CalendarIcon } from "lucide-react"
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button';
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from '@/components/ui/popover';
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
value?: Date | string | null
|
value?: Date | string | null;
|
||||||
onChange?: (date: Date | undefined) => void
|
onChange?: (date: Date | undefined) => void;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
className?: string
|
className?: string;
|
||||||
name?: string
|
name?: string;
|
||||||
id?: string
|
id?: string;
|
||||||
min?: Date
|
min?: Date;
|
||||||
max?: Date
|
max?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatePicker({
|
function DatePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "Pilih tanggal",
|
placeholder = 'Pilih tanggal',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
name,
|
name,
|
||||||
@ -35,18 +35,18 @@ function DatePicker({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
const date = React.useMemo(() => {
|
const date = React.useMemo(() => {
|
||||||
if (!value) return undefined
|
if (!value) return undefined;
|
||||||
if (value instanceof Date) return value
|
if (value instanceof Date) return value;
|
||||||
return new Date(value)
|
return new Date(value);
|
||||||
}, [value])
|
}, [value]);
|
||||||
|
|
||||||
const formattedDate = React.useMemo(() => {
|
const formattedDate = React.useMemo(() => {
|
||||||
if (!date) return ""
|
if (!date) return '';
|
||||||
return format(date, "dd MMM yyyy", { locale: id })
|
return format(date, 'dd MMM yyyy', { locale: id });
|
||||||
}, [date])
|
}, [date]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@ -56,9 +56,9 @@ function DatePicker({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start text-left font-normal",
|
'w-full justify-start text-left font-normal',
|
||||||
!date && "text-muted-foreground",
|
!date && 'text-muted-foreground',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
@ -70,22 +70,26 @@ function DatePicker({
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date}
|
||||||
onSelect={(selectedDate) => {
|
onSelect={(selectedDate) => {
|
||||||
onChange?.(selectedDate)
|
onChange?.(selectedDate);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={(date) => {
|
disabled={(date) => {
|
||||||
if (min && date < min) return true
|
if (min && date < min) return true;
|
||||||
if (max && date > max) return true
|
if (max && date > max) return true;
|
||||||
return false
|
return false;
|
||||||
}}
|
}}
|
||||||
initialFocus
|
initialFocus
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
{name && (
|
{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>
|
</Popover>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DatePicker }
|
export { DatePicker };
|
||||||
|
|||||||
@ -49,7 +49,12 @@ function acceptToLabels(accept: string): string[] {
|
|||||||
|
|
||||||
return accept
|
return accept
|
||||||
.split(',')
|
.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);
|
.filter((v, i, a) => a.indexOf(v) === i);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +65,16 @@ function formatMaxSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const uploadId = useId();
|
const uploadId = useId();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@ -96,13 +110,15 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
|||||||
onChange(key);
|
onChange(key);
|
||||||
onFileMeta?.({ size: file.size, type: file.type });
|
onFileMeta?.({ size: file.size, type: file.type });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.';
|
const message =
|
||||||
|
err instanceof UploadError
|
||||||
|
? err.message
|
||||||
|
: 'Gagal mengunggah file.';
|
||||||
setError(message);
|
setError(message);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
setFileSize(null);
|
setFileSize(null);
|
||||||
onFileMeta?.(null);
|
onFileMeta?.(null);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -139,13 +161,21 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
|||||||
id={uploadId}
|
id={uploadId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Attachment state={state} orientation="horizontal" className='w-full'>
|
<Attachment
|
||||||
|
state={state}
|
||||||
|
orientation="horizontal"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<AttachmentTrigger
|
<AttachmentTrigger
|
||||||
onClick={() => inputRef.current?.click()}
|
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 ? (
|
{preview ? (
|
||||||
<img src={preview} alt={fileName ?? 'Preview'} />
|
<img src={preview} alt={fileName ?? 'Preview'} />
|
||||||
) : existingUrl && value ? (
|
) : existingUrl && value ? (
|
||||||
@ -162,18 +192,24 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
|||||||
<>
|
<>
|
||||||
<AttachmentTitle>{fileName}</AttachmentTitle>
|
<AttachmentTitle>{fileName}</AttachmentTitle>
|
||||||
<AttachmentDescription>
|
<AttachmentDescription>
|
||||||
{fileSize ? formatFileSize(fileSize) : 'Terupload'}
|
{fileSize
|
||||||
|
? formatFileSize(fileSize)
|
||||||
|
: 'Terupload'}
|
||||||
</AttachmentDescription>
|
</AttachmentDescription>
|
||||||
</>
|
</>
|
||||||
) : uploading ? (
|
) : uploading ? (
|
||||||
<>
|
<>
|
||||||
<AttachmentTitle>Mengunggah...</AttachmentTitle>
|
<AttachmentTitle>Mengunggah...</AttachmentTitle>
|
||||||
<AttachmentDescription>Memproses file</AttachmentDescription>
|
<AttachmentDescription>
|
||||||
|
Memproses file
|
||||||
|
</AttachmentDescription>
|
||||||
</>
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<>
|
<>
|
||||||
<AttachmentTitle>Gagal</AttachmentTitle>
|
<AttachmentTitle>Gagal</AttachmentTitle>
|
||||||
<AttachmentDescription>{error}</AttachmentDescription>
|
<AttachmentDescription>
|
||||||
|
{error}
|
||||||
|
</AttachmentDescription>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -187,7 +223,10 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
|||||||
|
|
||||||
{value && (
|
{value && (
|
||||||
<AttachmentActions>
|
<AttachmentActions>
|
||||||
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
|
<AttachmentAction
|
||||||
|
aria-label="Hapus file"
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
<X />
|
<X />
|
||||||
</AttachmentAction>
|
</AttachmentAction>
|
||||||
</AttachmentActions>
|
</AttachmentActions>
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
type ImagePreviewModalProps = {
|
type ImagePreviewModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -8,7 +13,13 @@ type ImagePreviewModalProps = {
|
|||||||
alt?: string;
|
alt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Preview' }: ImagePreviewModalProps) {
|
export function ImagePreviewModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
src,
|
||||||
|
title,
|
||||||
|
alt = 'Preview',
|
||||||
|
}: ImagePreviewModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent showCloseButton>
|
<DialogContent showCloseButton>
|
||||||
@ -21,7 +32,7 @@ export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Previ
|
|||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className="w-full rounded-lg object-contain max-h-[80vh]"
|
className="max-h-[80vh] w-full rounded-lg object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -9,7 +9,12 @@ interface LocationMapProps {
|
|||||||
zoom?: number;
|
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 mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(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.control.zoom({ position: 'topright' }).addTo(map);
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
@ -46,5 +52,11 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
|
|||||||
};
|
};
|
||||||
}, [latitude, longitude, zoom]);
|
}, [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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,17 +19,14 @@ export function NavUser() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<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"
|
data-test="header-user-menu-button"
|
||||||
>
|
>
|
||||||
<UserInfo user={auth.user} />
|
<UserInfo user={auth.user} />
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent className="w-56 rounded-lg" align="end">
|
||||||
className="w-56 rounded-lg"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<UserMenuContent user={auth.user} />
|
<UserMenuContent user={auth.user} />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -12,7 +12,8 @@ export function PWAUpdateToast() {
|
|||||||
|
|
||||||
const handleUpdateAvailable = () => {
|
const handleUpdateAvailable = () => {
|
||||||
toast.info('Update tersedia!', {
|
toast.info('Update tersedia!', {
|
||||||
description: 'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
|
description:
|
||||||
|
'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
action: {
|
action: {
|
||||||
label: 'Update',
|
label: 'Update',
|
||||||
@ -28,7 +29,10 @@ export function PWAUpdateToast() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('sw-offline-ready', handleOfflineReady);
|
window.removeEventListener('sw-offline-ready', handleOfflineReady);
|
||||||
window.removeEventListener('sw-update-available', handleUpdateAvailable);
|
window.removeEventListener(
|
||||||
|
'sw-update-available',
|
||||||
|
handleUpdateAvailable,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,9 @@ export function RupiahInput({
|
|||||||
className,
|
className,
|
||||||
}: RupiahInputProps) {
|
}: RupiahInputProps) {
|
||||||
const isControlled = value !== undefined;
|
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);
|
const lastValidRef = useRef(isControlled ? value : defaultValue);
|
||||||
|
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
|
|||||||
@ -18,27 +18,54 @@ export function useFileUpload() {
|
|||||||
preview: null,
|
preview: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const upload = useCallback(async (file: File, folder?: string): Promise<string | null> => {
|
const upload = useCallback(
|
||||||
setState({ uploading: true, progress: 0, error: null, key: null, preview: null });
|
async (file: File, folder?: string): Promise<string | null> => {
|
||||||
|
setState({
|
||||||
|
uploading: true,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
key: null,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preview = URL.createObjectURL(file);
|
const preview = URL.createObjectURL(file);
|
||||||
setState((prev) => ({ ...prev, preview, progress: 30 }));
|
setState((prev) => ({ ...prev, preview, progress: 30 }));
|
||||||
|
|
||||||
const key = await uploadFile(file, folder);
|
const key = await uploadFile(file, folder);
|
||||||
setState((prev) => ({ ...prev, key, uploading: false, progress: 100 }));
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
key,
|
||||||
|
uploading: false,
|
||||||
|
progress: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof UploadError ? err.message : 'Terjadi kesalahan saat mengunggah file.';
|
const message =
|
||||||
setState((prev) => ({ ...prev, error: message, uploading: false }));
|
err instanceof UploadError
|
||||||
|
? err.message
|
||||||
|
: 'Terjadi kesalahan saat mengunggah file.';
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: message,
|
||||||
|
uploading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
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) => {
|
const setKey = useCallback((key: string | null) => {
|
||||||
|
|||||||
@ -9,8 +9,8 @@ function getInitial(name: string): string {
|
|||||||
export function useInitials(): GetInitialsFn {
|
export function useInitials(): GetInitialsFn {
|
||||||
return useCallback((fullName: string): string => {
|
return useCallback((fullName: string): string => {
|
||||||
if (!fullName) {
|
if (!fullName) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = fullName.trim().split(/\s+/u).filter(Boolean);
|
const names = fullName.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
const mql = window.matchMedia(
|
||||||
const onChange = () => {
|
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
);
|
||||||
}
|
const onChange = () => {
|
||||||
mql.addEventListener("change", onChange)
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,10 @@ export default function AppSidebarLayout({
|
|||||||
return (
|
return (
|
||||||
<AppShell variant="sidebar">
|
<AppShell variant="sidebar">
|
||||||
<AppSidebar />
|
<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} />
|
<AppSidebarHeader breadcrumbs={breadcrumbs} />
|
||||||
{children}
|
{children}
|
||||||
</AppContent>
|
</AppContent>
|
||||||
|
|||||||
@ -26,7 +26,11 @@ export default function AuthCardLayout({
|
|||||||
className="flex items-center gap-2 self-center font-medium"
|
className="flex items-center gap-2 self-center font-medium"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -73,9 +73,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
|||||||
|
|
||||||
<Separator className="my-6 lg:hidden" />
|
<Separator className="my-6 lg:hidden" />
|
||||||
|
|
||||||
<section className="flex-1 space-y-12">
|
<section className="flex-1 space-y-12">{children}</section>
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,7 +15,12 @@ export class UploadError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSessionCookie(): string {
|
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(
|
export async function requestPresignedUrl(
|
||||||
@ -31,7 +36,11 @@ export async function requestPresignedUrl(
|
|||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()),
|
'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) {
|
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> {
|
export async function uploadFile(file: File, folder?: string): Promise<string> {
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
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) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
throw new UploadError('Ukuran file melebihi batas 10MB.');
|
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);
|
await uploadToS3(upload_url, file);
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
@ -30,9 +30,7 @@ export function createCashAccountColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -46,9 +44,7 @@ export function createCashAccountColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama</span>
|
<span>Nama</span>
|
||||||
@ -68,9 +64,7 @@ export function createCashAccountColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Saldo</span>
|
<span>Saldo</span>
|
||||||
@ -101,16 +95,12 @@ export function createCashAccountColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(cashAccount)}
|
||||||
handleEdit(cashAccount)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { Form, Head, router } from '@inertiajs/react';
|
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 { useCallback, useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
@ -18,11 +24,28 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { index as cashAccountIndex, deposit, withdrawal } from '@/routes/admin/finance/cash-accounts';
|
import {
|
||||||
import { update as updateTransaction, destroy as destroyTransaction } from '@/routes/admin/finance/cash-accounts/transactions';
|
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 { createTransactionColumns } from './transaction-columns';
|
||||||
import type { CashTransaction } 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 [depositOpen, setDepositOpen] = useState(false);
|
||||||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
||||||
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
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 [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 [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 [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
|
||||||
const [editUploading, setEditUploading] = useState(false);
|
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 [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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: transactions.current_page,
|
current_page: transactions.current_page,
|
||||||
@ -86,24 +129,32 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
newFilters[key as keyof typeof newFilters] = value;
|
newFilters[key as keyof typeof newFilters] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get(cashAccountIndex(), {
|
router.get(
|
||||||
...newFilters,
|
cashAccountIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...newFilters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
router.get(cashAccountIndex(), {
|
router.get(
|
||||||
page: 1,
|
cashAccountIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,49 +169,68 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(cashAccountIndex(), {
|
router.get(
|
||||||
...filters,
|
cashAccountIndex(),
|
||||||
page,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(cashAccountIndex(), {
|
router.get(
|
||||||
...filters,
|
cashAccountIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: perPage,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(cashAccountIndex(), {
|
setSearch(value);
|
||||||
...filters,
|
router.get(
|
||||||
page: 1,
|
cashAccountIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search: value,
|
...filters,
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort, filters]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort, filters],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(cashAccountIndex(), {
|
router.get(
|
||||||
...filters,
|
cashAccountIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createTransactionColumns({
|
const columns = createTransactionColumns({
|
||||||
@ -207,7 +277,9 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.type ?? 'all'}
|
value={filters.type ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('type', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('type', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua Tipe" />
|
<SelectValue placeholder="Semua Tipe" />
|
||||||
@ -215,9 +287,15 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||||
<SelectItem value="deposit">Deposit</SelectItem>
|
<SelectItem value="deposit">Deposit</SelectItem>
|
||||||
<SelectItem value="withdrawal">Withdrawal</SelectItem>
|
<SelectItem value="withdrawal">
|
||||||
<SelectItem value="expense">Pengeluaran</SelectItem>
|
Withdrawal
|
||||||
<SelectItem value="transfer">Transfer</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="expense">
|
||||||
|
Pengeluaran
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="transfer">
|
||||||
|
Transfer
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -238,11 +316,17 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<ArrowDownToLine className="h-4 w-4" />
|
||||||
Deposit
|
Deposit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setWithdrawalOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setWithdrawalOpen(true)}
|
||||||
|
>
|
||||||
<ArrowUpFromLine className="h-4 w-4" />
|
<ArrowUpFromLine className="h-4 w-4" />
|
||||||
Withdrawal
|
Withdrawal
|
||||||
</Button>
|
</Button>
|
||||||
@ -279,20 +363,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
toolbar={filterToolbar}
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={depositOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
setDepositOpen(open);
|
open={depositOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDepositOpen(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDepositReceiptKey(null);
|
|
||||||
setDepositFileMeta(null);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogContent>
|
|
||||||
<Form action={deposit()} resetOnSuccess onSuccess={() => {
|
|
||||||
setDepositOpen(false);
|
|
||||||
setDepositReceiptKey(null);
|
setDepositReceiptKey(null);
|
||||||
setDepositFileMeta(null);
|
setDepositFileMeta(null);
|
||||||
}}>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<Form
|
||||||
|
action={deposit()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setDepositOpen(false);
|
||||||
|
setDepositReceiptKey(null);
|
||||||
|
setDepositFileMeta(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -301,46 +392,95 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Keterangan <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
<InputError
|
||||||
|
message={errors.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="receipt_key" value={depositReceiptKey ?? ''} />
|
Bukti{' '}
|
||||||
<input type="hidden" name="file_size" value={depositFileMeta?.size ?? ''} />
|
<span className="text-destructive">
|
||||||
<input type="hidden" name="file_mime_type" value={depositFileMeta?.type ?? ''} />
|
*
|
||||||
|
</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
|
<FileUpload
|
||||||
value={depositReceiptKey}
|
value={depositReceiptKey}
|
||||||
onChange={setDepositReceiptKey}
|
onChange={setDepositReceiptKey}
|
||||||
folder="cash-transaction"
|
folder="cash-transaction"
|
||||||
onUploadingChange={setDepositUploading}
|
onUploadingChange={
|
||||||
|
setDepositUploading
|
||||||
|
}
|
||||||
onFileMeta={setDepositFileMeta}
|
onFileMeta={setDepositFileMeta}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.receipt_key} />
|
<InputError
|
||||||
|
message={errors.receipt_key}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setDepositOpen(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setDepositOpen(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing || depositUploading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing || depositUploading
|
||||||
|
}
|
||||||
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: depositUploading
|
: depositUploading
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@ -349,20 +489,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={withdrawalOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
setWithdrawalOpen(open);
|
open={withdrawalOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setWithdrawalOpen(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setWithdrawalReceiptKey(null);
|
|
||||||
setWithdrawalFileMeta(null);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogContent>
|
|
||||||
<Form action={withdrawal()} resetOnSuccess onSuccess={() => {
|
|
||||||
setWithdrawalOpen(false);
|
|
||||||
setWithdrawalReceiptKey(null);
|
setWithdrawalReceiptKey(null);
|
||||||
setWithdrawalFileMeta(null);
|
setWithdrawalFileMeta(null);
|
||||||
}}>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<Form
|
||||||
|
action={withdrawal()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => {
|
||||||
|
setWithdrawalOpen(false);
|
||||||
|
setWithdrawalReceiptKey(null);
|
||||||
|
setWithdrawalFileMeta(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -371,46 +518,104 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Keterangan <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
<InputError
|
||||||
|
message={errors.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="receipt_key" value={withdrawalReceiptKey ?? ''} />
|
Bukti{' '}
|
||||||
<input type="hidden" name="file_size" value={withdrawalFileMeta?.size ?? ''} />
|
<span className="text-destructive">
|
||||||
<input type="hidden" name="file_mime_type" value={withdrawalFileMeta?.type ?? ''} />
|
*
|
||||||
|
</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
|
<FileUpload
|
||||||
value={withdrawalReceiptKey}
|
value={withdrawalReceiptKey}
|
||||||
onChange={setWithdrawalReceiptKey}
|
onChange={
|
||||||
|
setWithdrawalReceiptKey
|
||||||
|
}
|
||||||
folder="cash-transaction"
|
folder="cash-transaction"
|
||||||
onUploadingChange={setWithdrawalUploading}
|
onUploadingChange={
|
||||||
onFileMeta={setWithdrawalFileMeta}
|
setWithdrawalUploading
|
||||||
|
}
|
||||||
|
onFileMeta={
|
||||||
|
setWithdrawalFileMeta
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.receipt_key}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.receipt_key} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setWithdrawalOpen(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setWithdrawalOpen(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing || withdrawalUploading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing ||
|
||||||
|
withdrawalUploading
|
||||||
|
}
|
||||||
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: withdrawalUploading
|
: withdrawalUploading
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@ -431,61 +636,121 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={updateTransaction(editing.id)} resetOnSuccess onSuccess={() => {
|
<Form
|
||||||
setEditing(null);
|
action={updateTransaction(editing.id)}
|
||||||
setEditReceiptKey(null);
|
resetOnSuccess
|
||||||
setEditFileMeta(null);
|
onSuccess={() => {
|
||||||
}}>
|
setEditing(null);
|
||||||
|
setEditReceiptKey(null);
|
||||||
|
setEditFileMeta(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Transaksi</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Transaksi
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
defaultValue={
|
||||||
|
editing.amount
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Keterangan <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
defaultValue={editing.description}
|
defaultValue={
|
||||||
|
editing.description
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.description}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} />
|
Bukti{' '}
|
||||||
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} />
|
<span className="text-destructive">
|
||||||
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} />
|
*
|
||||||
|
</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
|
<FileUpload
|
||||||
value={editReceiptKey}
|
value={editReceiptKey}
|
||||||
onChange={setEditReceiptKey}
|
onChange={setEditReceiptKey}
|
||||||
folder="cash-transaction"
|
folder="cash-transaction"
|
||||||
onUploadingChange={setEditUploading}
|
onUploadingChange={
|
||||||
existingUrl={editing.receipt_url}
|
setEditUploading
|
||||||
|
}
|
||||||
|
existingUrl={
|
||||||
|
editing.receipt_url
|
||||||
|
}
|
||||||
onFileMeta={setEditFileMeta}
|
onFileMeta={setEditFileMeta}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.receipt_key} />
|
<InputError
|
||||||
|
message={errors.receipt_key}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing || editUploading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing || editUploading
|
||||||
|
}
|
||||||
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: editUploading
|
: editUploading
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -33,14 +33,18 @@ export type CashTransaction = {
|
|||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return date.toLocaleDateString('id-ID', {
|
return (
|
||||||
day: '2-digit',
|
date.toLocaleDateString('id-ID', {
|
||||||
month: 'short',
|
day: '2-digit',
|
||||||
year: 'numeric',
|
month: 'short',
|
||||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
}) +
|
||||||
minute: '2-digit',
|
' ' +
|
||||||
});
|
date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeLabel(type: string): string {
|
function getTypeLabel(type: string): string {
|
||||||
@ -105,9 +109,7 @@ export function createTransactionColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -121,9 +123,7 @@ export function createTransactionColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Tanggal</span>
|
<span>Tanggal</span>
|
||||||
@ -142,8 +142,14 @@ export function createTransactionColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{getTypeLabel(transaction.type)}</span>
|
<span className="font-medium">
|
||||||
<span className="text-xs text-muted-foreground">{getReferenceLabel(transaction.reference?.type ?? '')}</span>
|
{getTypeLabel(transaction.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{getReferenceLabel(
|
||||||
|
transaction.reference?.type ?? '',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -155,9 +161,7 @@ export function createTransactionColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Jumlah</span>
|
<span>Jumlah</span>
|
||||||
@ -169,8 +173,15 @@ export function createTransactionColumns(
|
|||||||
const isDeposit = transaction.type === 'deposit';
|
const isDeposit = transaction.type === 'deposit';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={isDeposit ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
|
<span
|
||||||
{isDeposit ? '+' : '-'} {formatCurrency(row.getValue('amount') as number)}
|
className={
|
||||||
|
isDeposit
|
||||||
|
? 'font-medium text-green-600'
|
||||||
|
: 'font-medium text-red-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDeposit ? '+' : '-'}{' '}
|
||||||
|
{formatCurrency(row.getValue('amount') as number)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -182,9 +193,7 @@ export function createTransactionColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Saldo Setelah</span>
|
<span>Saldo Setelah</span>
|
||||||
@ -192,14 +201,18 @@ export function createTransactionColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
cell: ({ row }) => (
|
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 <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 }) => {
|
cell: ({ row }) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
const canEdit = transaction.type === 'deposit' || transaction.type === 'withdrawal';
|
const canEdit =
|
||||||
|
transaction.type === 'deposit' ||
|
||||||
|
transaction.type === 'withdrawal';
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return <span className="block text-center">-</span>;
|
return <span className="block text-center">-</span>;
|
||||||
@ -252,9 +272,7 @@ export function createTransactionColumns(
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -262,7 +280,9 @@ export function createTransactionColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteClick(transaction)}
|
onClick={() =>
|
||||||
|
handleDeleteClick(transaction)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -30,14 +36,18 @@ export type EmployeeAdvance = {
|
|||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return date.toLocaleDateString('id-ID', {
|
return (
|
||||||
day: '2-digit',
|
date.toLocaleDateString('id-ID', {
|
||||||
month: 'short',
|
day: '2-digit',
|
||||||
year: 'numeric',
|
month: 'short',
|
||||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
}) +
|
||||||
minute: '2-digit',
|
' ' +
|
||||||
});
|
date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortDate(dateString: string): string {
|
function formatShortDate(dateString: string): string {
|
||||||
@ -100,9 +110,7 @@ export function createEmployeeAdvanceColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -116,9 +124,7 @@ export function createEmployeeAdvanceColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Tanggal</span>
|
<span>Tanggal</span>
|
||||||
@ -135,7 +141,11 @@ export function createEmployeeAdvanceColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const employee = row.original.employee;
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Jumlah</span>
|
<span>Jumlah</span>
|
||||||
@ -155,7 +163,7 @@ export function createEmployeeAdvanceColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-red-600 font-medium">
|
<span className="font-medium text-red-600">
|
||||||
- {formatCurrency(row.getValue('amount') as number)}
|
- {formatCurrency(row.getValue('amount') as number)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -164,7 +172,9 @@ export function createEmployeeAdvanceColumns(
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
cell: ({ row }) => (
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Jatuh Tempo</span>
|
<span>Jatuh Tempo</span>
|
||||||
@ -184,7 +192,9 @@ export function createEmployeeAdvanceColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -17,7 +17,14 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { createEmployeeAdvanceColumns } from './columns';
|
||||||
import type { EmployeeAdvance } 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 [approving, setApproving] = useState<EmployeeAdvance | null>(null);
|
||||||
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
|
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
|
||||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
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 [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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: employeeAdvances.current_page,
|
current_page: employeeAdvances.current_page,
|
||||||
@ -72,9 +84,13 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(approve(approving.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setApproving(null),
|
approve(approving.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setApproving(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePay() {
|
function handlePay() {
|
||||||
@ -82,51 +98,74 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(pay(paying.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setPaying(null),
|
pay(paying.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setPaying(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(employeeAdvanceIndex.url(), {
|
router.get(
|
||||||
page,
|
employeeAdvanceIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(employeeAdvanceIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
employeeAdvanceIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(employeeAdvanceIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
employeeAdvanceIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(employeeAdvanceIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
employeeAdvanceIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createEmployeeAdvanceColumns({
|
const columns = createEmployeeAdvanceColumns({
|
||||||
@ -147,13 +186,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
Kasbon
|
Kasbon
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
setCreateOpen(open);
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDueDate(undefined);
|
setDueDate(undefined);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -164,57 +206,98 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
<Form
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Kasbon</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Kasbon
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah{' '} <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="description">
|
<Label htmlFor="description">
|
||||||
Keterangan{' '} <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.description
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="due_date">
|
<Label htmlFor="due_date">
|
||||||
Jatuh Tempo{' '} <span className="text-destructive">*</span>
|
Jatuh Tempo{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</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
|
<DatePicker
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={setDueDate}
|
onChange={setDueDate}
|
||||||
placeholder="Pilih jatuh tempo"
|
placeholder="Pilih jatuh tempo"
|
||||||
min={new Date()}
|
min={new Date()}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.due_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.due_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -256,43 +339,95 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
<Form
|
||||||
setEditing(null);
|
action={update(editing.id)}
|
||||||
setEditingDueDate(undefined);
|
resetOnSuccess
|
||||||
}}>
|
onSuccess={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setEditingDueDate(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Kasbon</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Kasbon
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
Jumlah{' '}
|
||||||
<InputError message={errors.amount} />
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<RupiahInput
|
||||||
|
name="amount"
|
||||||
|
defaultValue={
|
||||||
|
editing.amount
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<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
|
<Input
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
defaultValue={editing.description}
|
defaultValue={
|
||||||
|
editing.description
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.description
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-due_date">Jatuh Tempo{' '} <span className="text-destructive">*</span></Label>
|
<Label htmlFor="edit-due_date">
|
||||||
<input type="hidden" name="due_date" value={editingDueDate ? editingDueDate.toISOString().split('T')[0] : ''} />
|
Jatuh Tempo{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="due_date"
|
||||||
|
value={
|
||||||
|
editingDueDate
|
||||||
|
? editingDueDate
|
||||||
|
.toISOString()
|
||||||
|
.split(
|
||||||
|
'T',
|
||||||
|
)[0]
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={editingDueDate}
|
value={editingDueDate}
|
||||||
onChange={setEditingDueDate}
|
onChange={
|
||||||
|
setEditingDueDate
|
||||||
|
}
|
||||||
placeholder="Pilih jatuh tempo"
|
placeholder="Pilih jatuh tempo"
|
||||||
min={new Date()}
|
min={new Date()}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.due_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.due_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -28,14 +28,18 @@ export type Expense = {
|
|||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return date.toLocaleDateString('id-ID', {
|
return (
|
||||||
day: '2-digit',
|
date.toLocaleDateString('id-ID', {
|
||||||
month: 'short',
|
day: '2-digit',
|
||||||
year: 'numeric',
|
month: 'short',
|
||||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
}) +
|
||||||
minute: '2-digit',
|
' ' +
|
||||||
});
|
date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateColumnsParams = {
|
type CreateColumnsParams = {
|
||||||
@ -78,9 +82,7 @@ export function createExpenseColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -94,9 +96,7 @@ export function createExpenseColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Tanggal</span>
|
<span>Tanggal</span>
|
||||||
@ -111,7 +111,9 @@ export function createExpenseColumns(
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: () => <span>Keterangan</span>,
|
header: () => <span>Keterangan</span>,
|
||||||
cell: ({ row }) => (
|
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 <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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Jumlah</span>
|
<span>Jumlah</span>
|
||||||
@ -144,7 +149,7 @@ export function createExpenseColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-red-600 font-medium">
|
<span className="font-medium text-red-600">
|
||||||
- {formatCurrency(row.getValue('amount') as number)}
|
- {formatCurrency(row.getValue('amount') as number)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -176,16 +181,12 @@ export function createExpenseColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(expense)}
|
||||||
handleEdit(expense)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -17,7 +17,12 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { createExpenseColumns } from './columns';
|
||||||
import type { Expense } from './columns';
|
import type { Expense } from './columns';
|
||||||
|
|
||||||
@ -35,14 +40,25 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Expense | null>(null);
|
const [editing, setEditing] = useState<Expense | null>(null);
|
||||||
const [deleting, setDeleting] = 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 [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
|
||||||
const [createUploading, setCreateUploading] = useState(false);
|
const [createUploading, setCreateUploading] = useState(false);
|
||||||
const [editUploading, setEditUploading] = useState(false);
|
const [editUploading, setEditUploading] = useState(false);
|
||||||
const [createFileMeta, setCreateFileMeta] = useState<{ size: number; type: string } | null>(null);
|
const [createFileMeta, setCreateFileMeta] = useState<{
|
||||||
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null);
|
size: number;
|
||||||
|
type: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [editFileMeta, setEditFileMeta] = useState<{
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
} | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: expenses.current_page,
|
current_page: expenses.current_page,
|
||||||
@ -52,45 +68,64 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(expenseIndex.url(), {
|
router.get(
|
||||||
page,
|
expenseIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(expenseIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
expenseIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(expenseIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
expenseIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(expenseIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
expenseIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -122,14 +157,17 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
Pengeluaran
|
Pengeluaran
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
setCreateOpen(open);
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCreateReceiptKey(null);
|
setCreateReceiptKey(null);
|
||||||
setCreateFileMeta(null);
|
setCreateFileMeta(null);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -140,69 +178,130 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => {
|
<Form
|
||||||
setCreateOpen(false);
|
action={store()}
|
||||||
setCreateReceiptKey(null);
|
resetOnSuccess
|
||||||
setCreateFileMeta(null);
|
onSuccess={() => {
|
||||||
}}>
|
setCreateOpen(false);
|
||||||
|
setCreateReceiptKey(null);
|
||||||
|
setCreateFileMeta(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Pengeluaran</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Pengeluaran
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah{' '} <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="description">
|
<Label htmlFor="description">
|
||||||
Keterangan{' '} <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.description
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="receipt_key" value={createReceiptKey ?? ''} />
|
Bukti{' '}
|
||||||
<input type="hidden" name="file_size" value={createFileMeta?.size ?? ''} />
|
<span className="text-destructive">
|
||||||
<input type="hidden" name="file_mime_type" value={createFileMeta?.type ?? ''} />
|
*
|
||||||
|
</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
|
<FileUpload
|
||||||
value={createReceiptKey}
|
value={createReceiptKey}
|
||||||
onChange={setCreateReceiptKey}
|
onChange={
|
||||||
|
setCreateReceiptKey
|
||||||
|
}
|
||||||
folder="expense"
|
folder="expense"
|
||||||
onUploadingChange={setCreateUploading}
|
onUploadingChange={
|
||||||
onFileMeta={setCreateFileMeta}
|
setCreateUploading
|
||||||
|
}
|
||||||
|
onFileMeta={
|
||||||
|
setCreateFileMeta
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.receipt_key
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.receipt_key} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing || createUploading}
|
disabled={
|
||||||
|
processing ||
|
||||||
|
createUploading
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: createUploading
|
: createUploading
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@ -240,48 +339,114 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
<Form
|
||||||
setEditing(null);
|
action={update(editing.id)}
|
||||||
setEditReceiptKey(null);
|
resetOnSuccess
|
||||||
setEditFileMeta(null);
|
onSuccess={() => {
|
||||||
}}>
|
setEditing(null);
|
||||||
|
setEditReceiptKey(null);
|
||||||
|
setEditFileMeta(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Pengeluaran</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Pengeluaran
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
Jumlah{' '}
|
||||||
<InputError message={errors.amount} />
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<RupiahInput
|
||||||
|
name="amount"
|
||||||
|
defaultValue={
|
||||||
|
editing.amount
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<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
|
<Input
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
defaultValue={editing.description}
|
defaultValue={
|
||||||
|
editing.description
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.description
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Bukti{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} />
|
Bukti{' '}
|
||||||
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} />
|
<span className="text-destructive">
|
||||||
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} />
|
*
|
||||||
|
</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
|
<FileUpload
|
||||||
value={editReceiptKey}
|
value={editReceiptKey}
|
||||||
onChange={setEditReceiptKey}
|
onChange={
|
||||||
|
setEditReceiptKey
|
||||||
|
}
|
||||||
folder="expense"
|
folder="expense"
|
||||||
onUploadingChange={setEditUploading}
|
onUploadingChange={
|
||||||
existingUrl={editing.receipt_url}
|
setEditUploading
|
||||||
onFileMeta={setEditFileMeta}
|
}
|
||||||
|
existingUrl={
|
||||||
|
editing.receipt_url
|
||||||
|
}
|
||||||
|
onFileMeta={
|
||||||
|
setEditFileMeta
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.receipt_key
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.receipt_key} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@ -296,13 +461,16 @@ export default function ExpenseIndex({ expenses }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing || editUploading}
|
disabled={
|
||||||
|
processing ||
|
||||||
|
editUploading
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: editUploading
|
: editUploading
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -27,8 +27,19 @@ export type PayrollPeriod = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
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 {
|
function formatPeriod(period: PayrollPeriod): string {
|
||||||
@ -72,9 +83,7 @@ export function createPayrollPeriodColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -88,9 +97,7 @@ export function createPayrollPeriodColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Periode</span>
|
<span>Periode</span>
|
||||||
@ -98,14 +105,18 @@ export function createPayrollPeriodColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">{formatPeriod(row.original)}</span>
|
<span className="font-medium">
|
||||||
|
{formatPeriod(row.original)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'payrolls_count',
|
accessorKey: 'payrolls_count',
|
||||||
header: () => <span>Jumlah Karyawan</span>,
|
header: () => <span>Jumlah Karyawan</span>,
|
||||||
cell: ({ row }) => (
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Total Gaji</span>
|
<span>Total Gaji</span>
|
||||||
@ -126,7 +135,10 @@ export function createPayrollPeriodColumns(
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">
|
<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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -137,9 +149,7 @@ export function createPayrollPeriodColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Bonus</span>
|
<span>Bonus</span>
|
||||||
@ -147,11 +157,19 @@ export function createPayrollPeriodColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
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 (
|
return (
|
||||||
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
|
<span
|
||||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
className={
|
||||||
|
value > 0
|
||||||
|
? 'font-medium text-green-600'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value > 0 ? '+ ' : ''}
|
||||||
|
{formatCurrency(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -163,9 +181,7 @@ export function createPayrollPeriodColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Potongan</span>
|
<span>Potongan</span>
|
||||||
@ -173,11 +189,20 @@ export function createPayrollPeriodColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
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 (
|
return (
|
||||||
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}>
|
<span
|
||||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
className={
|
||||||
|
value > 0
|
||||||
|
? 'font-medium text-red-600'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value > 0 ? '- ' : ''}
|
||||||
|
{formatCurrency(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -195,13 +220,19 @@ export function createPayrollPeriodColumns(
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 text-xs">
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
{paid > 0 && (
|
{paid > 0 && (
|
||||||
<span className="text-green-600">{paid} dibayar</span>
|
<span className="text-green-600">
|
||||||
|
{paid} dibayar
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{unpaid > 0 && (
|
{unpaid > 0 && (
|
||||||
<span className="text-yellow-600">{unpaid} menunggu</span>
|
<span className="text-yellow-600">
|
||||||
|
{unpaid} menunggu
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{cancelled > 0 && (
|
{cancelled > 0 && (
|
||||||
<span className="text-red-600">{cancelled} dibatalkan</span>
|
<span className="text-red-600">
|
||||||
|
{cancelled} dibatalkan
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -229,11 +260,7 @@ export function createPayrollPeriodColumns(
|
|||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" asChild>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href={showUrl(period.id)}>
|
<Link href={showUrl(period.id)}>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -28,15 +28,29 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
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) {
|
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||||
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||||
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: payrollPeriods.current_page,
|
current_page: payrollPeriods.current_page,
|
||||||
@ -47,64 +61,91 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
|||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
if (!closing) {
|
if (!closing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(close(closing.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setClosing(null),
|
close(closing.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setClosing(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReopen() {
|
function handleReopen() {
|
||||||
if (!reopening) {
|
if (!reopening) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(reopen(reopening.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setReopening(null),
|
reopen(reopening.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setReopening(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(payrollPeriodsIndex(), {
|
router.get(
|
||||||
page,
|
payrollPeriodsIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(payrollPeriodsIndex(), {
|
router.get(
|
||||||
page: 1,
|
payrollPeriodsIndex(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(payrollPeriodsIndex(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
payrollPeriodsIndex(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(payrollPeriodsIndex(), {
|
router.get(
|
||||||
page: 1,
|
payrollPeriodsIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createPayrollPeriodColumns({
|
const columns = createPayrollPeriodColumns({
|
||||||
@ -145,8 +186,8 @@ return;
|
|||||||
open={closing !== null}
|
open={closing !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setClosing(null);
|
setClosing(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Tutup Periode Gaji"
|
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.`}
|
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}
|
open={reopening !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setReopening(null);
|
setReopening(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Buka Periode Gaji"
|
title="Buka Periode Gaji"
|
||||||
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
|
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -65,22 +71,28 @@ type CreateColumnsParams = {
|
|||||||
handlePay: (payroll: Payroll) => void;
|
handlePay: (payroll: Payroll) => void;
|
||||||
handleCancel: (payroll: Payroll) => void;
|
handleCancel: (payroll: Payroll) => void;
|
||||||
handleAddAdjustment: (payroll: Payroll) => void;
|
handleAddAdjustment: (payroll: Payroll) => void;
|
||||||
handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void;
|
handleDeleteAdjustment: (
|
||||||
|
adjustment: PayrollAdjustment,
|
||||||
|
payrollId: number,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPayrollColumns(
|
export function createPayrollColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<Payroll>[] {
|
): ColumnDef<Payroll>[] {
|
||||||
const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params;
|
const {
|
||||||
|
handlePay,
|
||||||
|
handleCancel,
|
||||||
|
handleAddAdjustment,
|
||||||
|
handleDeleteAdjustment,
|
||||||
|
} = params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -93,7 +105,11 @@ export function createPayrollColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const employee = row.original.employee;
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Gaji Pokok</span>
|
<span>Gaji Pokok</span>
|
||||||
@ -113,7 +127,9 @@ export function createPayrollColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Bonus</span>
|
<span>Bonus</span>
|
||||||
@ -136,8 +150,13 @@ export function createPayrollColumns(
|
|||||||
const value = row.getValue('bonus_amount') as number;
|
const value = row.getValue('bonus_amount') as number;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={value > 0 ? 'text-green-600 font-medium' : ''}>
|
<span
|
||||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
className={
|
||||||
|
value > 0 ? 'font-medium text-green-600' : ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value > 0 ? '+ ' : ''}
|
||||||
|
{formatCurrency(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -149,9 +168,7 @@ export function createPayrollColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Potongan</span>
|
<span>Potongan</span>
|
||||||
@ -162,8 +179,11 @@ export function createPayrollColumns(
|
|||||||
const value = row.getValue('deduction_amount') as number;
|
const value = row.getValue('deduction_amount') as number;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={value > 0 ? 'text-red-600 font-medium' : ''}>
|
<span
|
||||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
className={value > 0 ? 'font-medium text-red-600' : ''}
|
||||||
|
>
|
||||||
|
{value > 0 ? '- ' : ''}
|
||||||
|
{formatCurrency(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -175,9 +195,7 @@ export function createPayrollColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
@ -185,7 +203,9 @@ export function createPayrollColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{adjustments.map((adj: PayrollAdjustment) => (
|
{adjustments.map((adj: PayrollAdjustment) => (
|
||||||
<div key={adj.id} className="flex items-center gap-1 text-xs">
|
<div
|
||||||
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-600'}>
|
key={adj.id}
|
||||||
{adj.type === 'bonus' ? '+' : '-'} {formatCurrency(adj.amount)}
|
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>
|
||||||
<span className="text-muted-foreground truncate max-w-[100px]">
|
<span className="max-w-[100px] truncate text-muted-foreground">
|
||||||
{adj.description}
|
{adj.description}
|
||||||
</span>
|
</span>
|
||||||
{payroll.status === 'unpaid' && (
|
{payroll.status === 'unpaid' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteAdjustment(adj, payroll.id)}
|
onClick={() =>
|
||||||
|
handleDeleteAdjustment(
|
||||||
|
adj,
|
||||||
|
payroll.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
className="text-destructive hover:text-destructive/80"
|
className="text-destructive hover:text-destructive/80"
|
||||||
>
|
>
|
||||||
<XCircle className="h-3 w-3" />
|
<XCircle className="h-3 w-3" />
|
||||||
@ -250,7 +285,9 @@ export function createPayrollColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleAddAdjustment(payroll)}
|
onClick={() =>
|
||||||
|
handleAddAdjustment(payroll)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -265,7 +302,9 @@ export function createPayrollColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handlePay(payroll)}
|
onClick={() =>
|
||||||
|
handlePay(payroll)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -280,7 +319,9 @@ export function createPayrollColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleCancel(payroll)}
|
onClick={() =>
|
||||||
|
handleCancel(payroll)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4 text-destructive" />
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -15,9 +15,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
||||||
import {
|
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||||
index as payrollPeriodsIndex
|
|
||||||
} from '@/routes/admin/finance/payroll-periods';
|
|
||||||
import {
|
import {
|
||||||
cancel as payrollCancel,
|
cancel as payrollCancel,
|
||||||
pay as payrollPay,
|
pay as payrollPay,
|
||||||
@ -40,31 +38,55 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
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) {
|
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||||
const [paying, setPaying] = useState<Payroll | null>(null);
|
const [paying, setPaying] = useState<Payroll | null>(null);
|
||||||
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
||||||
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(null);
|
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
|
||||||
const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
const [deletingAdjustment, setDeletingAdjustment] = useState<{
|
||||||
|
adjustment: PayrollAdjustment;
|
||||||
|
payrollId: number;
|
||||||
|
} | null>(null);
|
||||||
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
||||||
|
|
||||||
function handlePay() {
|
function handlePay() {
|
||||||
if (!paying) return;
|
if (!paying) return;
|
||||||
|
|
||||||
router.post(payrollPay(paying.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setPaying(null),
|
payrollPay(paying.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setPaying(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
if (!cancelling) return;
|
if (!cancelling) return;
|
||||||
|
|
||||||
router.post(payrollCancel(cancelling.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setCancelling(null),
|
payrollCancel(cancelling.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setCancelling(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteAdjustment() {
|
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 totalBaseSalary = payrollPeriod.payrolls.reduce(
|
||||||
const totalBonus = payrollPeriod.payrolls.reduce((sum, p) => sum + p.bonus_amount, 0);
|
(sum, p) => sum + p.base_salary,
|
||||||
const totalDeduction = payrollPeriod.payrolls.reduce((sum, p) => sum + p.deduction_amount, 0);
|
0,
|
||||||
const totalAmount = payrollPeriod.payrolls.reduce((sum, p) => sum + p.total_amount, 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 (
|
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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year}
|
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
|
||||||
|
{payrollPeriod.year}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{payrollPeriod.payrolls.length} karyawan · Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'}
|
{payrollPeriod.payrolls.length} karyawan ·
|
||||||
|
Status:{' '}
|
||||||
|
{payrollPeriod.status === 'open'
|
||||||
|
? 'Terbuka'
|
||||||
|
: 'Ditutup'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant='outline'>
|
<Button asChild variant="outline">
|
||||||
<a href={payrollPeriodsIndex.url()}>
|
<a href={payrollPeriodsIndex.url()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Kembali
|
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="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Gaji Pokok</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-lg font-semibold">{formatCurrency(totalBaseSalary)}</p>
|
Total Gaji Pokok
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{formatCurrency(totalBaseSalary)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Bonus</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-lg font-semibold text-green-600">{formatCurrency(totalBonus)}</p>
|
Total Bonus
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
{formatCurrency(totalBonus)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Potongan</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-lg font-semibold text-red-600">{formatCurrency(totalDeduction)}</p>
|
Total Potongan
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-red-600">
|
||||||
|
{formatCurrency(totalDeduction)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Gaji</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-lg font-semibold">{formatCurrency(totalAmount)}</p>
|
Total Gaji
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{formatCurrency(totalAmount)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -142,12 +199,15 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog Tambah Penyesuaian */}
|
{/* Dialog Tambah Penyesuaian */}
|
||||||
<Dialog open={addingAdjustment !== null} onOpenChange={(open) => {
|
<Dialog
|
||||||
if (!open) {
|
open={addingAdjustment !== null}
|
||||||
setAddingAdjustment(null);
|
onOpenChange={(open) => {
|
||||||
setAdjustmentType('bonus');
|
if (!open) {
|
||||||
}
|
setAddingAdjustment(null);
|
||||||
}}>
|
setAdjustmentType('bonus');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{addingAdjustment && (
|
{addingAdjustment && (
|
||||||
<Form
|
<Form
|
||||||
@ -161,59 +221,108 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
|||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Penyesuaian</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Penyesuaian
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jenis{' '} <span className="text-destructive">*</span>
|
Jenis{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<input type="hidden" name="type" value={adjustmentType} />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="type"
|
||||||
|
value={adjustmentType}
|
||||||
|
/>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={adjustmentType}
|
value={adjustmentType}
|
||||||
onValueChange={setAdjustmentType}
|
onValueChange={
|
||||||
|
setAdjustmentType
|
||||||
|
}
|
||||||
className="flex gap-4"
|
className="flex gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RadioGroupItem value="bonus" id="bonus" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="bonus" className="font-normal">Bonus</Label>
|
value="bonus"
|
||||||
|
id="bonus"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="bonus"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Bonus
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RadioGroupItem value="deduction" id="deduction" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="deduction" className="font-normal">Potongan</Label>
|
value="deduction"
|
||||||
|
id="deduction"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="deduction"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Potongan
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.type} />
|
<InputError
|
||||||
|
message={errors.type}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Jumlah{' '} <span className="text-destructive">*</span>
|
Jumlah{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="amount" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.amount} />
|
name="amount"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="adjustment-description">
|
<Label htmlFor="adjustment-description">
|
||||||
Keterangan{' '} <span className="text-destructive">*</span>
|
Keterangan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="adjustment-description"
|
id="adjustment-description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Masukkan keterangan"
|
placeholder="Masukkan keterangan"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.description} />
|
<InputError
|
||||||
|
message={errors.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAddingAdjustment(null)}
|
onClick={() =>
|
||||||
|
setAddingAdjustment(null)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing}>
|
<Button
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
{processing
|
||||||
|
? 'Menyimpan...'
|
||||||
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -3,12 +3,32 @@ import { LocationMap } from '@/components/location-map';
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import {
|
||||||
import { index as attendanceIndex, store, update } from '@/routes/admin/hr/attendances';
|
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 { Head, router } from '@inertiajs/react';
|
||||||
import { addMonths, format, subMonths } from 'date-fns';
|
import { addMonths, format, subMonths } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
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 { useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -56,12 +76,20 @@ function getFirstDayOfMonth(year: number, month: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSameDay(d1: Date, d2: Date): boolean {
|
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 {
|
function checkIsToday(date: Date): boolean {
|
||||||
const t = new Date();
|
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 {
|
function isWeekend(date: Date): boolean {
|
||||||
@ -69,7 +97,11 @@ function isWeekend(date: Date): boolean {
|
|||||||
return day === 0 || day === 6;
|
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;
|
if (!checkInAt) return false;
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const h = d.getHours();
|
const h = d.getHours();
|
||||||
@ -77,7 +109,11 @@ function isLate(checkInAt: string | null, officeHour: number, officeMinute: numb
|
|||||||
return h > officeHour || (h === officeHour && m > officeMinute);
|
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;
|
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
|
||||||
const d = new Date(checkInAt);
|
const d = new Date(checkInAt);
|
||||||
const officeStart = new Date(d);
|
const officeStart = new Date(d);
|
||||||
@ -93,14 +129,29 @@ function formatMinutes(minutes: number | null): string {
|
|||||||
return `${hours} jam ${mins} menit`;
|
return `${hours} jam ${mins} menit`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AttendanceIndex({ attendances, todayAttendance, currentYear, currentMonth, monthStats, hrSettings }: Props) {
|
export default function AttendanceIndex({
|
||||||
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time.split(':').map(Number);
|
attendances,
|
||||||
const [viewDate, setViewDate] = useState(new Date(currentYear, currentMonth - 1));
|
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 [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
const [showCamera, setShowCamera] = useState(false);
|
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 [locationLoading, setLocationLoading] = useState(false);
|
||||||
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(null);
|
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const viewYear = viewDate.getFullYear();
|
const viewYear = viewDate.getFullYear();
|
||||||
const viewMonth = viewDate.getMonth() + 1;
|
const viewMonth = viewDate.getMonth() + 1;
|
||||||
@ -121,7 +172,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||||
const firstDay = getFirstDayOfMonth(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 }[] = [];
|
const days: { day: number; isCurrentMonth: boolean; date: Date }[] = [];
|
||||||
|
|
||||||
@ -129,18 +183,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
const d = prevMonthDays - i;
|
const d = prevMonthDays - i;
|
||||||
const m = viewMonth === 1 ? 12 : viewMonth - 1;
|
const m = viewMonth === 1 ? 12 : viewMonth - 1;
|
||||||
const y = viewMonth === 1 ? viewYear - 1 : viewYear;
|
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++) {
|
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;
|
const remaining = 42 - days.length;
|
||||||
for (let i = 1; i <= remaining; i++) {
|
for (let i = 1; i <= remaining; i++) {
|
||||||
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
||||||
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
|
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;
|
return days;
|
||||||
@ -164,20 +230,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
setLocationLoading(false);
|
setLocationLoading(false);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('photo', dataUrl);
|
formData.append('photo', dataUrl);
|
||||||
formData.append('latitude', position.coords.latitude.toString());
|
formData.append(
|
||||||
formData.append('longitude', position.coords.longitude.toString());
|
'latitude',
|
||||||
|
position.coords.latitude.toString(),
|
||||||
|
);
|
||||||
|
formData.append(
|
||||||
|
'longitude',
|
||||||
|
position.coords.longitude.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
if (actionType === 'check-in') {
|
if (actionType === 'check-in') {
|
||||||
router.post(store(), formData, { preserveScroll: true });
|
router.post(store(), formData, { preserveScroll: true });
|
||||||
} else if (actionType === 'check-out' && todayAttendance) {
|
} else if (actionType === 'check-out' && todayAttendance) {
|
||||||
router.put(update(todayAttendance.id), formData, { preserveScroll: true });
|
router.put(update(todayAttendance.id), formData, {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
setLocationLoading(false);
|
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 className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Presensi</h2>
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Presensi
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert Status Presensi */}
|
{/* 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-4 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||||
<span className="text-muted-foreground">Masuk:</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatTime(todayAttendance?.check_in_at)}</span>
|
Masuk:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatTime(
|
||||||
|
todayAttendance?.check_in_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasCheckedOut ? (
|
{hasCheckedOut ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||||
<span className="text-muted-foreground">Pulang:</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatTime(todayAttendance?.check_out_at)}</span>
|
Pulang:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatTime(
|
||||||
|
todayAttendance?.check_out_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Clock className="h-3.5 w-3.5 text-orange-500" />
|
<Clock className="h-3.5 w-3.5 text-orange-500" />
|
||||||
<span className="text-muted-foreground">Pulang:</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium text-orange-600">Belum</span>
|
Pulang:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-orange-600">
|
||||||
|
Belum
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{locationLoading && (
|
{locationLoading && (
|
||||||
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Mendapatkan lokasi...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -259,7 +357,11 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCheckOut}
|
onClick={handleCheckOut}
|
||||||
disabled={!hasCheckedIn || hasCheckedOut || locationLoading}
|
disabled={
|
||||||
|
!hasCheckedIn ||
|
||||||
|
hasCheckedOut ||
|
||||||
|
locationLoading
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Presensi Pulang
|
Presensi Pulang
|
||||||
@ -277,8 +379,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
<CalendarDays className="h-5 w-5 text-blue-600" />
|
<CalendarDays className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Hari Kerja</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-lg font-bold">{monthStats.working_days}</p>
|
Hari Kerja
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{monthStats.working_days}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -288,8 +394,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Hadir</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-lg font-bold">{monthStats.present}</p>
|
Hadir
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{monthStats.present}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -299,8 +409,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
<UserX className="h-5 w-5 text-red-600" />
|
<UserX className="h-5 w-5 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Tidak Hadir</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-lg font-bold">{monthStats.absent}</p>
|
Tidak Hadir
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{monthStats.absent}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -310,41 +424,72 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
<Wallet className="h-5 w-5 text-amber-600" />
|
<Wallet className="h-5 w-5 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Cuti</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-lg font-bold">{monthStats.leave}</p>
|
Cuti
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{monthStats.leave}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Calendar Header */}
|
||||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
||||||
<div className="bg-muted px-3 py-0.5">
|
<div className="bg-muted px-3 py-0.5">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
<span className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
{format(viewDate, 'MMM', { locale: id })}
|
{format(viewDate, 'MMM', {
|
||||||
|
locale: id,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1">
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
{format(viewDate, 'MMMM yyyy', { locale: id })}
|
{format(viewDate, 'MMMM yyyy', {
|
||||||
|
locale: id,
|
||||||
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
Hari ini
|
||||||
</Button>
|
</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" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -353,7 +498,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
{/* Weekday Headers */}
|
{/* Weekday Headers */}
|
||||||
<div className="grid grid-cols-7 border-b">
|
<div className="grid grid-cols-7 border-b">
|
||||||
{WEEKDAYS.map((day) => (
|
{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}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -364,8 +512,14 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
{calendarDays.map((cell, idx) => {
|
{calendarDays.map((cell, idx) => {
|
||||||
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
||||||
const attendance = attendanceDates.get(dateStr);
|
const attendance = attendanceDates.get(dateStr);
|
||||||
const isSelected = isSameDay(cell.date, selectedDate);
|
const isSelected = isSameDay(
|
||||||
const isTodayDate = isSameDay(cell.date, new Date());
|
cell.date,
|
||||||
|
selectedDate,
|
||||||
|
);
|
||||||
|
const isTodayDate = isSameDay(
|
||||||
|
cell.date,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@ -373,20 +527,39 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
cellDate.setHours(0, 0, 0, 0);
|
cellDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const isPastDate = cellDate < today;
|
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 late = attendance
|
||||||
const lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0;
|
? isLate(
|
||||||
|
attendance.check_in_at,
|
||||||
|
officeHour,
|
||||||
|
officeMinute,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
const lateMins = attendance
|
||||||
|
? getLateMinutes(
|
||||||
|
attendance.check_in_at,
|
||||||
|
officeHour,
|
||||||
|
officeMinute,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDate(cell.date);
|
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 ${
|
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={{
|
style={{
|
||||||
borderRight: '1px solid var(--border)',
|
borderRight: '1px solid var(--border)',
|
||||||
@ -399,8 +572,8 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: isTodayDate
|
: isTodayDate
|
||||||
? 'bg-muted text-foreground'
|
? 'bg-muted text-foreground'
|
||||||
: 'text-foreground'
|
: 'text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cell.day}
|
{cell.day}
|
||||||
@ -409,15 +582,40 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
<div className="mt-1 flex flex-col gap-0.5">
|
<div className="mt-1 flex flex-col gap-0.5">
|
||||||
{attendance && (
|
{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'}`}>
|
<span
|
||||||
{late ? 'Terlambat' : 'Hadir'}
|
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>
|
||||||
<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 && (
|
{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 && (
|
{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">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<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>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{detailAttendance && (
|
{detailAttendance && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<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 ? (
|
{detailAttendance.check_in_photo ? (
|
||||||
<img
|
<img
|
||||||
src={detailAttendance.check_in_photo}
|
src={
|
||||||
|
detailAttendance.check_in_photo
|
||||||
|
}
|
||||||
alt="Foto Masuk"
|
alt="Foto Masuk"
|
||||||
className="w-full rounded-lg border object-cover"
|
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">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<LogIn className="h-4 w-4 text-green-600" />
|
<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>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<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 ? (
|
{detailAttendance.check_out_photo ? (
|
||||||
<img
|
<img
|
||||||
src={detailAttendance.check_out_photo}
|
src={
|
||||||
|
detailAttendance.check_out_photo
|
||||||
|
}
|
||||||
alt="Foto Pulang"
|
alt="Foto Pulang"
|
||||||
className="w-full rounded-lg border object-cover"
|
className="w-full rounded-lg border object-cover"
|
||||||
/>
|
/>
|
||||||
@ -485,22 +704,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
|||||||
{detailAttendance.check_out_at ? (
|
{detailAttendance.check_out_at ? (
|
||||||
<>
|
<>
|
||||||
<LogOut className="h-4 w-4 text-blue-600" />
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
<LocationMap
|
||||||
latitude={detailAttendance.check_in_latitude}
|
latitude={
|
||||||
longitude={detailAttendance.check_in_longitude}
|
detailAttendance.check_in_latitude
|
||||||
|
}
|
||||||
|
longitude={
|
||||||
|
detailAttendance.check_in_longitude
|
||||||
|
}
|
||||||
height="200px"
|
height="200px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,16 +56,19 @@ type CreateColumnsParams = {
|
|||||||
export function createEmployeeColumns(
|
export function createEmployeeColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<Employee>[] {
|
): ColumnDef<Employee>[] {
|
||||||
const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params;
|
const {
|
||||||
|
handleEdit,
|
||||||
|
handleDeleteClick,
|
||||||
|
handleResetPassword,
|
||||||
|
toggleActiveUrl,
|
||||||
|
} = params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -80,9 +83,7 @@ export function createEmployeeColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama</span>
|
<span>Nama</span>
|
||||||
@ -111,9 +112,7 @@ export function createEmployeeColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
@ -133,7 +132,9 @@ export function createEmployeeColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const employee = row.original;
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
@ -158,7 +157,9 @@ export function createEmployeeColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -179,9 +180,13 @@ export function createEmployeeColumns(
|
|||||||
size="sm"
|
size="sm"
|
||||||
checked={employee.is_active}
|
checked={employee.is_active}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
router.post(toggleActiveUrl(employee.id), {}, {
|
router.post(
|
||||||
preserveScroll: true,
|
toggleActiveUrl(employee.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -211,9 +216,7 @@ export function createEmployeeColumns(
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -221,7 +224,9 @@ export function createEmployeeColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleResetPassword(employee)}
|
onClick={() =>
|
||||||
|
handleResetPassword(employee)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -236,7 +241,9 @@ export function createEmployeeColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteClick(employee)}
|
onClick={() =>
|
||||||
|
handleDeleteClick(employee)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export default function EmployeeCreate() {
|
|||||||
Tambah Pegawai
|
Tambah Pegawai
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant='outline'>
|
<Button asChild variant="outline">
|
||||||
<a href={employeeIndex.url()}>
|
<a href={employeeIndex.url()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Kembali
|
Kembali
|
||||||
@ -52,9 +52,7 @@ export default function EmployeeCreate() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Form
|
<Form action={store()}>
|
||||||
action={store()}
|
|
||||||
>
|
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-6">
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
Email <span className="text-destructive">*</span>
|
Email{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -73,18 +74,25 @@ export default function EmployeeCreate() {
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="Masukkan email"
|
placeholder="Masukkan email"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.email} />
|
<InputError
|
||||||
|
message={errors.email}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="username">
|
<Label htmlFor="username">
|
||||||
Username <span className="text-destructive">*</span>
|
Username{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="Masukkan username"
|
placeholder="Masukkan username"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.username} />
|
<InputError
|
||||||
|
message={errors.username}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -96,35 +104,63 @@ export default function EmployeeCreate() {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="full_name">
|
<Label htmlFor="full_name">
|
||||||
Nama Lengkap <span className="text-destructive">*</span>
|
Nama Lengkap{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="full_name"
|
id="full_name"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
placeholder="Masukkan nama lengkap"
|
placeholder="Masukkan nama lengkap"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.full_name} />
|
<InputError
|
||||||
|
message={errors.full_name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone_number">
|
<Label htmlFor="phone_number">
|
||||||
No. Telepon
|
No. Telepon
|
||||||
</Label>
|
</Label>
|
||||||
<PhoneNumberInput name="phone_number"/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
<InputError message={errors.phone_number} />
|
<InputError
|
||||||
|
message={errors.phone_number}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jenis Kelamin</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="male" id="gender-male" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
|
value="male"
|
||||||
|
id="gender-male"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="gender-male"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Laki-laki
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="female" id="gender-female" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
|
value="female"
|
||||||
|
id="gender-female"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="gender-female"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Perempuan
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.gender} />
|
<InputError
|
||||||
|
message={errors.gender}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Lahir</Label>
|
<Label>Tanggal Lahir</Label>
|
||||||
@ -134,7 +170,9 @@ export default function EmployeeCreate() {
|
|||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
placeholder="Pilih tanggal lahir"
|
placeholder="Pilih tanggal lahir"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.birth_date} />
|
<InputError
|
||||||
|
message={errors.birth_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="address">
|
<Label htmlFor="address">
|
||||||
@ -146,7 +184,9 @@ export default function EmployeeCreate() {
|
|||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -157,14 +197,21 @@ export default function EmployeeCreate() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-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
|
<DatePicker
|
||||||
name="join_date"
|
name="join_date"
|
||||||
value={joinDate}
|
value={joinDate}
|
||||||
onChange={setJoinDate}
|
onChange={setJoinDate}
|
||||||
placeholder="Pilih tanggal masuk"
|
placeholder="Pilih tanggal masuk"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.join_date} />
|
<InputError
|
||||||
|
message={errors.join_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Keluar</Label>
|
<Label>Tanggal Keluar</Label>
|
||||||
@ -174,36 +221,68 @@ export default function EmployeeCreate() {
|
|||||||
onChange={setResignDate}
|
onChange={setResignDate}
|
||||||
placeholder="Pilih tanggal keluar"
|
placeholder="Pilih tanggal keluar"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.resign_date} />
|
<InputError
|
||||||
|
message={errors.resign_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<Select name="employment_status" defaultValue="full_time">
|
Status Kepegawaian{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="employment_status"
|
||||||
|
defaultValue="full_time"
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih status kepegawaian" />
|
<SelectValue placeholder="Pilih status kepegawaian" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="full_time">Full Time</SelectItem>
|
<SelectItem value="full_time">
|
||||||
<SelectItem value="part_time">Part Time</SelectItem>
|
Full Time
|
||||||
<SelectItem value="contract">Kontrak</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="internship">Magang</SelectItem>
|
<SelectItem value="part_time">
|
||||||
<SelectItem value="resigned">Keluar</SelectItem>
|
Part Time
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="contract">
|
||||||
|
Kontrak
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="internship">
|
||||||
|
Magang
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="resigned">
|
||||||
|
Keluar
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.employment_status} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.employment_status
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="base_salary">
|
<Label htmlFor="base_salary">
|
||||||
Gaji Pokok <span className="text-destructive">*</span>
|
Gaji Pokok{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="base_salary" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.base_salary} />
|
name="base_salary"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.base_salary}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -45,13 +45,19 @@ type Props = {
|
|||||||
|
|
||||||
export default function EmployeeEdit({ employee }: Props) {
|
export default function EmployeeEdit({ employee }: Props) {
|
||||||
const [joinDate, setJoinDate] = useState<Date | undefined>(
|
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>(
|
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>(
|
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 (
|
return (
|
||||||
@ -65,7 +71,7 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
Edit Pegawai
|
Edit Pegawai
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant='outline'>
|
<Button asChild variant="outline">
|
||||||
<a href={employeeIndex.url()}>
|
<a href={employeeIndex.url()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Kembali
|
Kembali
|
||||||
@ -87,7 +93,10 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
Email <span className="text-destructive">*</span>
|
Email{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -96,11 +105,16 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
placeholder="Masukkan email"
|
placeholder="Masukkan email"
|
||||||
defaultValue={employee.email}
|
defaultValue={employee.email}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.email} />
|
<InputError
|
||||||
|
message={errors.email}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="username">
|
<Label htmlFor="username">
|
||||||
Username <span className="text-destructive">*</span>
|
Username{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
@ -108,7 +122,9 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
placeholder="Masukkan username"
|
placeholder="Masukkan username"
|
||||||
defaultValue={employee.username}
|
defaultValue={employee.username}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.username} />
|
<InputError
|
||||||
|
message={errors.username}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -120,36 +136,71 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="full_name">
|
<Label htmlFor="full_name">
|
||||||
Nama Lengkap <span className="text-destructive">*</span>
|
Nama Lengkap{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="full_name"
|
id="full_name"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
placeholder="Masukkan nama lengkap"
|
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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone_number">
|
<Label htmlFor="phone_number">
|
||||||
No. Telepon
|
No. Telepon
|
||||||
</Label>
|
</Label>
|
||||||
<PhoneNumberInput name="phone_number"/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
<InputError message={errors.phone_number} />
|
<InputError
|
||||||
|
message={errors.phone_number}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jenis Kelamin</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="male" id="gender-male" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
|
value="male"
|
||||||
|
id="gender-male"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="gender-male"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Laki-laki
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="female" id="gender-female" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
|
value="female"
|
||||||
|
id="gender-female"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="gender-female"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Perempuan
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.gender} />
|
<InputError
|
||||||
|
message={errors.gender}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Lahir</Label>
|
<Label>Tanggal Lahir</Label>
|
||||||
@ -159,7 +210,9 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
onChange={setBirthDate}
|
onChange={setBirthDate}
|
||||||
placeholder="Pilih tanggal lahir"
|
placeholder="Pilih tanggal lahir"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.birth_date} />
|
<InputError
|
||||||
|
message={errors.birth_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="address">
|
<Label htmlFor="address">
|
||||||
@ -170,9 +223,14 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={employee.user_profile?.address ?? ''}
|
defaultValue={
|
||||||
|
employee.user_profile
|
||||||
|
?.address ?? ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -183,14 +241,21 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-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
|
<DatePicker
|
||||||
name="join_date"
|
name="join_date"
|
||||||
value={joinDate}
|
value={joinDate}
|
||||||
onChange={setJoinDate}
|
onChange={setJoinDate}
|
||||||
placeholder="Pilih tanggal masuk"
|
placeholder="Pilih tanggal masuk"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.join_date} />
|
<InputError
|
||||||
|
message={errors.join_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Keluar</Label>
|
<Label>Tanggal Keluar</Label>
|
||||||
@ -200,36 +265,72 @@ export default function EmployeeEdit({ employee }: Props) {
|
|||||||
onChange={setResignDate}
|
onChange={setResignDate}
|
||||||
placeholder="Pilih tanggal keluar"
|
placeholder="Pilih tanggal keluar"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.resign_date} />
|
<InputError
|
||||||
|
message={errors.resign_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<Select name="employment_status" defaultValue={employee.employee?.employment_status ?? 'full_time'}>
|
Status Kepegawaian{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="employment_status"
|
||||||
|
defaultValue={
|
||||||
|
employee.employee
|
||||||
|
?.employment_status ??
|
||||||
|
'full_time'
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Pilih status kepegawaian" />
|
<SelectValue placeholder="Pilih status kepegawaian" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="full_time">Full Time</SelectItem>
|
<SelectItem value="full_time">
|
||||||
<SelectItem value="part_time">Part Time</SelectItem>
|
Full Time
|
||||||
<SelectItem value="contract">Kontrak</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="internship">Magang</SelectItem>
|
<SelectItem value="part_time">
|
||||||
<SelectItem value="resigned">Keluar</SelectItem>
|
Part Time
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="contract">
|
||||||
|
Kontrak
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="internship">
|
||||||
|
Magang
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="resigned">
|
||||||
|
Keluar
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<InputError message={errors.employment_status} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.employment_status
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="base_salary">
|
<Label htmlFor="base_salary">
|
||||||
Gaji Pokok <span className="text-destructive">*</span>
|
Gaji Pokok{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="base_salary" min={1} />
|
<RupiahInput
|
||||||
<InputError message={errors.base_salary} />
|
name="base_salary"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.base_salary}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -5,9 +5,26 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
|||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { PaginationState, SortState } from '@/components/data-table';
|
import type { PaginationState, SortState } from '@/components/data-table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
Popover,
|
||||||
import { destroy, create as employeeCreate, edit as employeeEdit, index as employeeIndex, toggleActive, resetPassword as resetPasswordRoute } from '@/routes/admin/hr/employees';
|
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 type { Employee } from './columns';
|
||||||
import { createEmployeeColumns } from './columns';
|
import { createEmployeeColumns } from './columns';
|
||||||
|
|
||||||
@ -28,10 +45,14 @@ type Props = {
|
|||||||
|
|
||||||
export default function EmployeeIndex({ employees, filters }: Props) {
|
export default function EmployeeIndex({ employees, filters }: Props) {
|
||||||
const [deleting, setDeleting] = useState<Employee | null>(null);
|
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 [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: employees.current_page,
|
current_page: employees.current_page,
|
||||||
@ -51,71 +72,98 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
newFilters[key as keyof typeof newFilters] = value;
|
newFilters[key as keyof typeof newFilters] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get(employeeIndex.url(), {
|
router.get(
|
||||||
...newFilters,
|
employeeIndex.url(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...newFilters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
router.get(employeeIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
employeeIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(employeeIndex.url(), {
|
router.get(
|
||||||
...filters,
|
employeeIndex.url(),
|
||||||
page,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(employeeIndex.url(), {
|
router.get(
|
||||||
...filters,
|
employeeIndex.url(),
|
||||||
page: 1,
|
{
|
||||||
per_page: perPage,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(employeeIndex.url(), {
|
setSearch(value);
|
||||||
...filters,
|
router.get(
|
||||||
page: 1,
|
employeeIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search: value,
|
...filters,
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort, filters]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort, filters],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(employeeIndex.url(), {
|
router.get(
|
||||||
...filters,
|
employeeIndex.url(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -133,9 +181,13 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(resetPasswordRoute.url(resetPasswordTarget.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setResetPasswordTarget(null),
|
resetPasswordRoute.url(resetPasswordTarget.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setResetPasswordTarget(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createEmployeeColumns({
|
const columns = createEmployeeColumns({
|
||||||
@ -183,17 +235,29 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.employment_status ?? 'all'}
|
value={filters.employment_status ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('employment_status', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('employment_status', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua Status" />
|
<SelectValue placeholder="Semua Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
<SelectItem value="all">
|
||||||
<SelectItem value="full_time">Full Time</SelectItem>
|
Semua Status
|
||||||
<SelectItem value="part_time">Part Time</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="contract">Kontrak</SelectItem>
|
<SelectItem value="full_time">
|
||||||
<SelectItem value="internship">Magang</SelectItem>
|
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="resigned">Keluar</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -205,7 +269,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.is_active ?? 'all'}
|
value={filters.is_active ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('is_active', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('is_active', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua" />
|
<SelectValue placeholder="Semua" />
|
||||||
@ -224,7 +290,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.gender ?? 'all'}
|
value={filters.gender ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('gender', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('gender', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua" />
|
<SelectValue placeholder="Semua" />
|
||||||
@ -232,7 +300,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Semua</SelectItem>
|
<SelectItem value="all">Semua</SelectItem>
|
||||||
<SelectItem value="male">Laki-laki</SelectItem>
|
<SelectItem value="male">Laki-laki</SelectItem>
|
||||||
<SelectItem value="female">Perempuan</SelectItem>
|
<SelectItem value="female">
|
||||||
|
Perempuan
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,13 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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 = {
|
export type LeaveRequest = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -74,16 +80,15 @@ type CreateColumnsParams = {
|
|||||||
export function createLeaveRequestColumns(
|
export function createLeaveRequestColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<LeaveRequest>[] {
|
): ColumnDef<LeaveRequest>[] {
|
||||||
const { handleEdit, handleDeleteClick, handleApprove, handleReject } = params;
|
const { handleEdit, handleDeleteClick, handleApprove, handleReject } =
|
||||||
|
params;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -96,7 +101,11 @@ export function createLeaveRequestColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const employee = row.original.employee;
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Tanggal Mulai</span>
|
<span>Tanggal Mulai</span>
|
||||||
@ -116,7 +123,9 @@ export function createLeaveRequestColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Tanggal Selesai</span>
|
<span>Tanggal Selesai</span>
|
||||||
@ -136,7 +143,9 @@ export function createLeaveRequestColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Hari</span>
|
<span>Hari</span>
|
||||||
@ -224,16 +231,12 @@ export function createLeaveRequestColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(leaveRequest)}
|
||||||
handleEdit(leaveRequest)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -15,9 +15,26 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
Popover,
|
||||||
import { approve, destroy, index as leaveRequestIndex, reject, store, update } from '@/routes/admin/hr/leave-requests';
|
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 type { LeaveRequest } from './columns';
|
||||||
import { createLeaveRequestColumns } 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 [rejecting, setRejecting] = useState<LeaveRequest | null>(null);
|
||||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||||
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(undefined);
|
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(
|
||||||
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(undefined);
|
undefined,
|
||||||
|
);
|
||||||
|
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: leaveRequests.current_page,
|
current_page: leaveRequests.current_page,
|
||||||
@ -76,24 +100,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
newFilters[key as keyof typeof newFilters] = value;
|
newFilters[key as keyof typeof newFilters] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get(leaveRequestIndex(), {
|
router.get(
|
||||||
...newFilters,
|
leaveRequestIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...newFilters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
router.get(leaveRequestIndex(), {
|
router.get(
|
||||||
page: 1,
|
leaveRequestIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,9 +144,13 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(approve(approving.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setApproving(null),
|
approve(approving.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setApproving(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReject() {
|
function handleReject() {
|
||||||
@ -122,55 +158,78 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(reject(rejecting.id), {}, {
|
router.post(
|
||||||
onSuccess: () => setRejecting(null),
|
reject(rejecting.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: () => setRejecting(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(leaveRequestIndex(), {
|
router.get(
|
||||||
...filters,
|
leaveRequestIndex(),
|
||||||
page,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(leaveRequestIndex(), {
|
router.get(
|
||||||
...filters,
|
leaveRequestIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: perPage,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(leaveRequestIndex(), {
|
setSearch(value);
|
||||||
...filters,
|
router.get(
|
||||||
page: 1,
|
leaveRequestIndex(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search: value,
|
...filters,
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort, filters]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort, filters],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(leaveRequestIndex(), {
|
router.get(
|
||||||
...filters,
|
leaveRequestIndex(),
|
||||||
page: 1,
|
{
|
||||||
per_page: pagination.per_page,
|
...filters,
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = createLeaveRequestColumns({
|
const columns = createLeaveRequestColumns({
|
||||||
@ -216,17 +275,29 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.status ?? 'all'}
|
value={filters.status ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('status', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('status', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua Status" />
|
<SelectValue placeholder="Semua Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
<SelectItem value="all">
|
||||||
<SelectItem value="pending">Menunggu</SelectItem>
|
Semua Status
|
||||||
<SelectItem value="approved">Disetujui</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="rejected">Ditolak</SelectItem>
|
<SelectItem value="pending">
|
||||||
<SelectItem value="cancelled">Dibatalkan</SelectItem>
|
Menunggu
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="approved">
|
||||||
|
Disetujui
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rejected">
|
||||||
|
Ditolak
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cancelled">
|
||||||
|
Dibatalkan
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -246,14 +317,17 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
Cuti
|
Cuti
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
<Dialog
|
||||||
setCreateOpen(open);
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setStartDate(undefined);
|
setStartDate(undefined);
|
||||||
setEndDate(undefined);
|
setEndDate(undefined);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -264,52 +338,97 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
<Form
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Permohonan Cuti</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Permohonan Cuti
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Tanggal Mulai{' '} <span className="text-destructive">*</span>
|
Tanggal Mulai{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</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
|
<DatePicker
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={setStartDate}
|
onChange={setStartDate}
|
||||||
placeholder="Pilih tanggal mulai"
|
placeholder="Pilih tanggal mulai"
|
||||||
min={new Date()}
|
min={new Date()}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.start_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.start_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Tanggal Selesai{' '} <span className="text-destructive">*</span>
|
Tanggal Selesai{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</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
|
<DatePicker
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={setEndDate}
|
onChange={setEndDate}
|
||||||
placeholder="Pilih tanggal selesai"
|
placeholder="Pilih tanggal selesai"
|
||||||
min={startDate}
|
min={startDate}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.end_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.end_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -353,40 +472,91 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
<Form
|
||||||
setEditing(null);
|
action={update(editing.id)}
|
||||||
setEditingStartDate(undefined);
|
resetOnSuccess
|
||||||
setEditingEndDate(undefined);
|
onSuccess={() => {
|
||||||
}}>
|
setEditing(null);
|
||||||
|
setEditingStartDate(undefined);
|
||||||
|
setEditingEndDate(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Permohonan Cuti</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Permohonan Cuti
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Mulai{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="start_date" value={editingStartDate ? editingStartDate.toISOString().split('T')[0] : ''} />
|
Tanggal Mulai{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="start_date"
|
||||||
|
value={
|
||||||
|
editingStartDate
|
||||||
|
? editingStartDate
|
||||||
|
.toISOString()
|
||||||
|
.split(
|
||||||
|
'T',
|
||||||
|
)[0]
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={editingStartDate}
|
value={editingStartDate}
|
||||||
onChange={setEditingStartDate}
|
onChange={
|
||||||
|
setEditingStartDate
|
||||||
|
}
|
||||||
placeholder="Pilih tanggal mulai"
|
placeholder="Pilih tanggal mulai"
|
||||||
min={new Date()}
|
min={new Date()}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.start_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.start_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Tanggal Selesai{' '} <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<input type="hidden" name="end_date" value={editingEndDate ? editingEndDate.toISOString().split('T')[0] : ''} />
|
Tanggal Selesai{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="end_date"
|
||||||
|
value={
|
||||||
|
editingEndDate
|
||||||
|
? editingEndDate
|
||||||
|
.toISOString()
|
||||||
|
.split(
|
||||||
|
'T',
|
||||||
|
)[0]
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={editingEndDate}
|
value={editingEndDate}
|
||||||
onChange={setEditingEndDate}
|
onChange={
|
||||||
|
setEditingEndDate
|
||||||
|
}
|
||||||
placeholder="Pilih tanggal selesai"
|
placeholder="Pilih tanggal selesai"
|
||||||
min={editingStartDate}
|
min={editingStartDate}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.end_date} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.end_date
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -28,9 +28,7 @@ export function createCategoryColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -44,9 +42,7 @@ export function createCategoryColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama</span>
|
<span>Nama</span>
|
||||||
@ -77,16 +73,12 @@ export function createCategoryColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(category)}
|
||||||
handleEdit(category)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -15,7 +15,12 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { createCategoryColumns } from './columns';
|
||||||
import type { Category } 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 [editing, setEditing] = useState<Category | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: categories.current_page,
|
current_page: categories.current_page,
|
||||||
@ -44,57 +52,76 @@ export default function CategoryIndex({ categories }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(categoryIndex.url(), {
|
router.get(
|
||||||
page,
|
categoryIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(categoryIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
categoryIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(categoryIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
categoryIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, {
|
search: value,
|
||||||
preserveState: true,
|
sort: sort.column,
|
||||||
replace: true,
|
direction: sort.direction,
|
||||||
});
|
},
|
||||||
}, [pagination.per_page, sort]);
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(categoryIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
categoryIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, {
|
sort: column,
|
||||||
preserveState: true,
|
direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -134,37 +161,49 @@ export default function CategoryIndex({ categories }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
<Form
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Kategori</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Kategori
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama{' '} <span className="text-destructive">*</span>
|
Nama{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama kategori"
|
placeholder="Masukkan nama kategori"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -205,24 +244,38 @@ export default function CategoryIndex({ categories }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
<Form
|
||||||
|
action={update(editing.id)}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setEditing(null)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Kategori</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Kategori
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<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
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama kategori"
|
placeholder="Masukkan nama kategori"
|
||||||
defaultValue={editing.name}
|
defaultValue={
|
||||||
|
editing.name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -30,9 +30,7 @@ export function createCustomerColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -46,9 +44,7 @@ export function createCustomerColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama</span>
|
<span>Nama</span>
|
||||||
@ -68,9 +64,7 @@ export function createCustomerColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>No. Telepon</span>
|
<span>No. Telepon</span>
|
||||||
@ -78,9 +72,7 @@ export function createCustomerColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
|
||||||
{row.getValue('phone_number') as string ?? '-'}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -90,9 +82,7 @@ export function createCustomerColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Alamat</span>
|
<span>Alamat</span>
|
||||||
@ -100,8 +90,8 @@ export function createCustomerColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="max-w-[200px] truncate block">
|
<span className="block max-w-[200px] truncate">
|
||||||
{row.getValue('address') as string ?? '-'}
|
{(row.getValue('address') as string) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -123,16 +113,12 @@ export function createCustomerColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(customer)}
|
||||||
handleEdit(customer)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -16,7 +16,12 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 type { Customer } from './columns';
|
||||||
import { createCustomerColumns } from './columns';
|
import { createCustomerColumns } from './columns';
|
||||||
|
|
||||||
@ -35,7 +40,10 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
const [editing, setEditing] = useState<Customer | null>(null);
|
const [editing, setEditing] = useState<Customer | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Customer | null>(null);
|
const [deleting, setDeleting] = useState<Customer | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: customers.current_page,
|
current_page: customers.current_page,
|
||||||
@ -45,57 +53,76 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(customerIndex.url(), {
|
router.get(
|
||||||
page,
|
customerIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(customerIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
customerIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(customerIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
customerIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, {
|
search: value,
|
||||||
preserveState: true,
|
sort: sort.column,
|
||||||
replace: true,
|
direction: sort.direction,
|
||||||
});
|
},
|
||||||
}, [pagination.per_page, sort]);
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(customerIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
customerIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, {
|
sort: column,
|
||||||
preserveState: true,
|
direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -135,32 +162,46 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
<Form
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Customer</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Customer
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama{' '} <span className="text-destructive">*</span>
|
Nama{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama customer"
|
placeholder="Masukkan nama customer"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone_number">
|
<Label htmlFor="phone_number">
|
||||||
No. Telepon
|
No. Telepon
|
||||||
</Label>
|
</Label>
|
||||||
<PhoneNumberInput name="phone_number"/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
<InputError message={errors.phone_number} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.phone_number
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="address">
|
<Label htmlFor="address">
|
||||||
@ -171,19 +212,23 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -224,27 +269,43 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
<Form
|
||||||
|
action={update(editing.id)}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setEditing(null)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Customer</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Customer
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<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
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama customer"
|
placeholder="Masukkan nama customer"
|
||||||
defaultValue={editing.name}
|
defaultValue={
|
||||||
|
editing.name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-phone_number">No. Telepon</Label>
|
<Label htmlFor="edit-phone_number">
|
||||||
|
No. Telepon
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-phone_number"
|
id="edit-phone_number"
|
||||||
name="phone_number"
|
name="phone_number"
|
||||||
@ -252,19 +313,33 @@ export default function CustomerIndex({ customers }: Props) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
placeholder="Masukkan nomor telepon"
|
placeholder="Masukkan nomor telepon"
|
||||||
defaultValue={editing.phone_number ?? ''}
|
defaultValue={
|
||||||
|
editing.phone_number ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.phone_number
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.phone_number} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-address">Alamat</Label>
|
<Label htmlFor="edit-address">
|
||||||
|
Alamat
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-address"
|
id="edit-address"
|
||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
defaultValue={editing.address ?? ''}
|
defaultValue={
|
||||||
|
editing.address ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -70,7 +70,10 @@ function formatNumber(num: number): string {
|
|||||||
return new Intl.NumberFormat('id-ID').format(num);
|
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();
|
const query = searchValue.toLowerCase().trim();
|
||||||
return query
|
return query
|
||||||
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
||||||
@ -93,7 +96,8 @@ export function createProductColumns(
|
|||||||
id: 'expand',
|
id: 'expand',
|
||||||
header: '',
|
header: '',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasVariants = (row.original.product_variants?.length ?? 0) > 0;
|
const hasVariants =
|
||||||
|
(row.original.product_variants?.length ?? 0) > 0;
|
||||||
|
|
||||||
if (!hasVariants) {
|
if (!hasVariants) {
|
||||||
return null;
|
return null;
|
||||||
@ -119,7 +123,8 @@ export function createProductColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'variant_names',
|
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,
|
header: () => null,
|
||||||
cell: () => null,
|
cell: () => null,
|
||||||
meta: {
|
meta: {
|
||||||
@ -131,9 +136,7 @@ export function createProductColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -147,9 +150,7 @@ export function createProductColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama Produk</span>
|
<span>Nama Produk</span>
|
||||||
@ -161,11 +162,11 @@ export function createProductColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">
|
<span className="font-medium">{product.name}</span>
|
||||||
{product.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{product.categories?.map((c) => c.name).join(', ') || '-'}
|
{product.categories
|
||||||
|
?.map((c) => c.name)
|
||||||
|
.join(', ') || '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -175,8 +176,14 @@ export function createProductColumns(
|
|||||||
id: 'variants',
|
id: 'variants',
|
||||||
header: () => <span>Varian</span>,
|
header: () => <span>Varian</span>,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
const searchValue =
|
||||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
(table
|
||||||
|
.getColumn('variant_names')
|
||||||
|
?.getFilterValue() as string) ?? '';
|
||||||
|
const variants = getFilteredVariants(
|
||||||
|
row.original.product_variants ?? [],
|
||||||
|
searchValue,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
<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',
|
headerClassName: 'w-[80px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
const searchValue =
|
||||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
(table
|
||||||
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
|
.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 (
|
return (
|
||||||
<span className="block text-center font-medium">
|
<span className="block text-center font-medium">
|
||||||
@ -206,15 +222,26 @@ export function createProductColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reject_stock',
|
id: 'reject_stock',
|
||||||
header: () => <span className="block text-center">Stok Reject</span>,
|
header: () => (
|
||||||
|
<span className="block text-center">Stok Reject</span>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[80px] text-center',
|
className: 'w-[80px] text-center',
|
||||||
headerClassName: 'w-[80px] text-center',
|
headerClassName: 'w-[80px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
const searchValue =
|
||||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
(table
|
||||||
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0);
|
.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 (
|
return (
|
||||||
<span className="block text-center font-medium">
|
<span className="block text-center font-medium">
|
||||||
@ -231,9 +258,18 @@ export function createProductColumns(
|
|||||||
headerClassName: 'w-[80px] text-center',
|
headerClassName: 'w-[80px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
const searchValue =
|
||||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
(table
|
||||||
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0);
|
.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 (
|
return (
|
||||||
<span className="block text-center font-medium">
|
<span className="block text-center font-medium">
|
||||||
@ -250,11 +286,26 @@ export function createProductColumns(
|
|||||||
headerClassName: 'w-[80px] text-center',
|
headerClassName: 'w-[80px] text-center',
|
||||||
},
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
const searchValue =
|
||||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
(table
|
||||||
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0);
|
.getColumn('variant_names')
|
||||||
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0);
|
?.getFilterValue() as string) ?? '';
|
||||||
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0);
|
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;
|
const total = totalStock + totalReject + totalRetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -271,9 +322,7 @@ export function createProductColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
@ -282,18 +331,26 @@ export function createProductColumns(
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
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';
|
const isChecked = product.status === 'active';
|
||||||
|
|
||||||
function handleToggle(checked: boolean) {
|
function handleToggle(checked: boolean) {
|
||||||
router.post(toggleStatusUrl(product.id), {}, {
|
router.post(
|
||||||
preserveScroll: true,
|
toggleStatusUrl(product.id),
|
||||||
});
|
{},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isToggleable) {
|
if (!isToggleable) {
|
||||||
return (
|
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)}
|
{getStatusLabel(product.status)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -306,7 +363,9 @@ export function createProductColumns(
|
|||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={handleToggle}
|
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)}
|
{getStatusLabel(product.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -336,9 +395,7 @@ export function createProductColumns(
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -346,7 +403,9 @@ export function createProductColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteClick(product)}
|
onClick={() =>
|
||||||
|
handleDeleteClick(product)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index as productIndex, store } from '@/routes/admin/master/products';
|
import { index as productIndex, store } from '@/routes/admin/master/products';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
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';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Category = {
|
type Category = {
|
||||||
@ -50,7 +57,8 @@ type VariantState = {
|
|||||||
export default function ProductCreate({ categories }: Props) {
|
export default function ProductCreate({ categories }: Props) {
|
||||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||||
const [useSamePrice, setUseSamePrice] = useState(true);
|
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[]>([
|
const [variants, setVariants] = useState<VariantState[]>([
|
||||||
{
|
{
|
||||||
name: '',
|
name: '',
|
||||||
@ -85,34 +93,43 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
const updateVariant = useCallback(
|
||||||
setVariants((prev) => {
|
(index: number, field: keyof VariantState, value: unknown) => {
|
||||||
const updated = [...prev];
|
setVariants((prev) => {
|
||||||
(updated[index] as Record<string, unknown>)[field] = value;
|
const updated = [...prev];
|
||||||
return updated;
|
(updated[index] as Record<string, unknown>)[field] = value;
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
const updateVariantPrice = useCallback(
|
||||||
setVariants((prev) => {
|
(variantIndex: number, priceIndex: number, value: number) => {
|
||||||
const updated = [...prev];
|
setVariants((prev) => {
|
||||||
updated[variantIndex] = {
|
const updated = [...prev];
|
||||||
...updated[variantIndex],
|
updated[variantIndex] = {
|
||||||
prices: updated[variantIndex].prices.map((p, i) =>
|
...updated[variantIndex],
|
||||||
i === priceIndex ? { ...p, price: value } : p
|
prices: updated[variantIndex].prices.map((p, i) =>
|
||||||
),
|
i === priceIndex ? { ...p, price: value } : p,
|
||||||
};
|
),
|
||||||
return updated;
|
};
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
const updateSharedPrice = useCallback(
|
||||||
setSharedPrices((prev) => {
|
(priceIndex: number, value: number) => {
|
||||||
const updated = [...prev];
|
setSharedPrices((prev) => {
|
||||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
const updated = [...prev];
|
||||||
return updated;
|
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@ -129,10 +146,16 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
const pastePrices = useCallback((variantIndex: number) => {
|
const pastePrices = useCallback((variantIndex: number) => {
|
||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
try {
|
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) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
updated[variantIndex] = {
|
||||||
|
...updated[variantIndex],
|
||||||
|
prices,
|
||||||
|
};
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@ -145,7 +168,7 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const sourcePrices = prev[variantIndex].prices;
|
const sourcePrices = prev[variantIndex].prices;
|
||||||
return prev.map((v, i) =>
|
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 {
|
return {
|
||||||
category_ids: categoryIds,
|
category_ids: categoryIds,
|
||||||
use_same_price: useSamePrice,
|
use_same_price: useSamePrice,
|
||||||
shared_prices: useSamePrice ? sharedPrices.map((p) => ({
|
shared_prices: useSamePrice
|
||||||
type: p.type,
|
? sharedPrices.map((p) => ({
|
||||||
price: Number(p.price),
|
type: p.type,
|
||||||
})) : [],
|
price: Number(p.price),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
variants: variantsRef.current.map((v) => ({
|
variants: variantsRef.current.map((v) => ({
|
||||||
name: v.name,
|
name: v.name,
|
||||||
stock: Number(v.stock),
|
stock: Number(v.stock),
|
||||||
@ -166,7 +191,10 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
photo_key: v.photo,
|
photo_key: v.photo,
|
||||||
prices: useSamePrice
|
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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<Button asChild variant="outline">
|
||||||
<a href={productIndex.url()}>
|
<a href={productIndex.url()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Produk <span className="text-destructive">*</span>
|
Nama Produk{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@ -213,25 +246,65 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
<InputError message={errors.name} />
|
<InputError message={errors.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
|
Status{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
name="status"
|
||||||
|
defaultValue="active"
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="active" id="status-active" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
value="active"
|
||||||
|
id="status-active"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-active"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Aktif
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
value="inactive"
|
||||||
|
id="status-inactive"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-inactive"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Non Aktif
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="draft" id="status-draft" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
value="draft"
|
||||||
|
id="status-draft"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-draft"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Draft
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.status} />
|
<InputError
|
||||||
|
message={errors.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<label
|
<label
|
||||||
@ -242,24 +315,43 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="category_ids[]"
|
name="category_ids[]"
|
||||||
value={category.id}
|
value={category.id}
|
||||||
checked={categoryIds.includes(category.id)}
|
checked={categoryIds.includes(
|
||||||
|
category.id,
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCategoryIds((prev) =>
|
setCategoryIds(
|
||||||
e.target.checked
|
(prev) =>
|
||||||
? [...prev, category.id]
|
e.target
|
||||||
: prev.filter((id) => id !== category.id)
|
.checked
|
||||||
|
? [
|
||||||
|
...prev,
|
||||||
|
category.id,
|
||||||
|
]
|
||||||
|
: prev.filter(
|
||||||
|
(
|
||||||
|
id,
|
||||||
|
) =>
|
||||||
|
id !==
|
||||||
|
category.id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span>{category.name}</span>
|
<span>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<InputError message={errors.category_ids} />
|
<InputError
|
||||||
|
message={errors.category_ids}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="description">Deskripsi</Label>
|
<Label htmlFor="description">
|
||||||
|
Deskripsi
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
@ -278,31 +370,80 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="use_same_price"
|
name="use_same_price"
|
||||||
value={useSamePrice ? '1' : '0'}
|
value={useSamePrice ? '1' : '0'}
|
||||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
onValueChange={(val) =>
|
||||||
|
setUseSamePrice(val === '1')
|
||||||
|
}
|
||||||
className="flex gap-6"
|
className="flex gap-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="1" id="price-same" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
value="1"
|
||||||
|
id="price-same"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="price-same"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Semua varian sama
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="0" id="price-different" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
value="0"
|
||||||
|
id="price-different"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="price-different"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Harga per varian
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
{useSamePrice && (
|
{useSamePrice && (
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
{PRICE_TYPES.map((priceType, priceIndex) => (
|
{PRICE_TYPES.map(
|
||||||
<div key={priceType.key} className="grid gap-2">
|
(priceType, priceIndex) => (
|
||||||
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
|
<div
|
||||||
<RupiahInput
|
key={priceType.key}
|
||||||
value={sharedPrices[priceIndex]?.price ?? 0}
|
className="grid gap-2"
|
||||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
>
|
||||||
/>
|
<Label>
|
||||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
Harga{' '}
|
||||||
</div>
|
{
|
||||||
))}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -313,125 +454,311 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
<CardTitle>Varian Produk</CardTitle>
|
<CardTitle>Varian Produk</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{variants.map((variant, variantIndex) => (
|
{variants.map(
|
||||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
(variant, variantIndex) => (
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
key={variantIndex}
|
||||||
<div className="flex items-center gap-1">
|
className="space-y-4 rounded-lg border p-4"
|
||||||
{!useSamePrice && (
|
>
|
||||||
<>
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => copyPrices(variantIndex)}
|
onClick={() =>
|
||||||
|
removeVariant(
|
||||||
|
variantIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{copiedIndex === variantIndex ? (
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Salin
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
</div>
|
||||||
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>
|
||||||
<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>
|
<Button
|
||||||
<Input
|
type="button"
|
||||||
value={variant.name}
|
variant="outline"
|
||||||
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
|
onClick={addVariant}
|
||||||
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}>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
@ -439,13 +766,19 @@ export default function ProductCreate({ categories }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing ||
|
||||||
|
variants.some((v) => v.uploading)
|
||||||
|
}
|
||||||
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: variants.some((v) => v.uploading)
|
: variants.some((v) => v.uploading)
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { index as productIndex, update } from '@/routes/admin/master/products';
|
import { index as productIndex, update } from '@/routes/admin/master/products';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
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';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Category = {
|
type Category = {
|
||||||
@ -56,9 +63,14 @@ function createEmptyPrices(): Array<{ type: string; price: number }> {
|
|||||||
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
|
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;
|
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 = {
|
type VariantState = {
|
||||||
@ -74,41 +86,54 @@ type VariantState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ProductEdit({ product, categories }: Props) {
|
export default function ProductEdit({ product, categories }: Props) {
|
||||||
const initialVariants: VariantState[] = product.product_variants.map((v) => ({
|
const initialVariants: VariantState[] = product.product_variants.map(
|
||||||
id: v.id,
|
(v) => ({
|
||||||
name: v.name,
|
id: v.id,
|
||||||
stock: v.stock,
|
name: v.name,
|
||||||
reject_stock: v.reject_stock,
|
stock: v.stock,
|
||||||
retail_stock: v.retail_stock,
|
reject_stock: v.reject_stock,
|
||||||
photo: v.photo_key,
|
retail_stock: v.retail_stock,
|
||||||
photoUrl: v.photo_url,
|
photo: v.photo_key,
|
||||||
uploading: false,
|
photoUrl: v.photo_url,
|
||||||
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
uploading: false,
|
||||||
}));
|
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const allSamePrice = initialVariants.length > 1
|
const allSamePrice =
|
||||||
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices))
|
initialVariants.length > 1
|
||||||
: true;
|
? 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 [useSamePrice, setUseSamePrice] = useState(allSamePrice);
|
||||||
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(
|
const [sharedPrices, setSharedPrices] = useState<
|
||||||
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices()
|
Array<{ type: string; price: number }>
|
||||||
|
>(
|
||||||
|
initialVariants.length > 0
|
||||||
|
? initialVariants[0].prices
|
||||||
|
: createEmptyPrices(),
|
||||||
);
|
);
|
||||||
const [variants, setVariants] = useState<VariantState[]>(
|
const [variants, setVariants] = useState<VariantState[]>(
|
||||||
initialVariants.length > 0 ? initialVariants : [
|
initialVariants.length > 0
|
||||||
{
|
? initialVariants
|
||||||
id: null,
|
: [
|
||||||
name: '',
|
{
|
||||||
stock: 0,
|
id: null,
|
||||||
reject_stock: 0,
|
name: '',
|
||||||
retail_stock: 0,
|
stock: 0,
|
||||||
photo: null,
|
reject_stock: 0,
|
||||||
photoUrl: null,
|
retail_stock: 0,
|
||||||
uploading: false,
|
photo: null,
|
||||||
prices: createEmptyPrices(),
|
photoUrl: null,
|
||||||
},
|
uploading: false,
|
||||||
]
|
prices: createEmptyPrices(),
|
||||||
|
},
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const variantsRef = useRef(variants);
|
const variantsRef = useRef(variants);
|
||||||
@ -135,34 +160,43 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
const updateVariant = useCallback(
|
||||||
setVariants((prev) => {
|
(index: number, field: keyof VariantState, value: unknown) => {
|
||||||
const updated = [...prev];
|
setVariants((prev) => {
|
||||||
(updated[index] as Record<string, unknown>)[field] = value;
|
const updated = [...prev];
|
||||||
return updated;
|
(updated[index] as Record<string, unknown>)[field] = value;
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
const updateVariantPrice = useCallback(
|
||||||
setVariants((prev) => {
|
(variantIndex: number, priceIndex: number, value: number) => {
|
||||||
const updated = [...prev];
|
setVariants((prev) => {
|
||||||
updated[variantIndex] = {
|
const updated = [...prev];
|
||||||
...updated[variantIndex],
|
updated[variantIndex] = {
|
||||||
prices: updated[variantIndex].prices.map((p, i) =>
|
...updated[variantIndex],
|
||||||
i === priceIndex ? { ...p, price: value } : p
|
prices: updated[variantIndex].prices.map((p, i) =>
|
||||||
),
|
i === priceIndex ? { ...p, price: value } : p,
|
||||||
};
|
),
|
||||||
return updated;
|
};
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
const updateSharedPrice = useCallback(
|
||||||
setSharedPrices((prev) => {
|
(priceIndex: number, value: number) => {
|
||||||
const updated = [...prev];
|
setSharedPrices((prev) => {
|
||||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
const updated = [...prev];
|
||||||
return updated;
|
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||||
});
|
return updated;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@ -179,10 +213,16 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
const pastePrices = useCallback((variantIndex: number) => {
|
const pastePrices = useCallback((variantIndex: number) => {
|
||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
try {
|
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) => {
|
setVariants((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
updated[variantIndex] = {
|
||||||
|
...updated[variantIndex],
|
||||||
|
prices,
|
||||||
|
};
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@ -195,7 +235,7 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
setVariants((prev) => {
|
setVariants((prev) => {
|
||||||
const sourcePrices = prev[variantIndex].prices;
|
const sourcePrices = prev[variantIndex].prices;
|
||||||
return prev.map((v, i) =>
|
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,
|
category_ids: categoryIds,
|
||||||
use_same_price: useSamePrice,
|
use_same_price: useSamePrice,
|
||||||
shared_prices: 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) => ({
|
variants: variantsRef.current.map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
@ -216,7 +259,10 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
photo_key: v.photo,
|
photo_key: v.photo,
|
||||||
prices: useSamePrice
|
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 h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<Button asChild variant="outline">
|
||||||
<a href={productIndex.url()}>
|
<a href={productIndex.url()}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Produk <span className="text-destructive">*</span>
|
Nama Produk{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@ -265,25 +316,65 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
<InputError message={errors.name} />
|
<InputError message={errors.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status <span className="text-destructive">*</span></Label>
|
<Label>
|
||||||
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4">
|
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">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="active" id="status-active" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
value="active"
|
||||||
|
id="status-active"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-active"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Aktif
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
value="inactive"
|
||||||
|
id="status-inactive"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-inactive"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Non Aktif
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="draft" id="status-draft" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
value="draft"
|
||||||
|
id="status-draft"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="status-draft"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Draft
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.status} />
|
<InputError
|
||||||
|
message={errors.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<label
|
<label
|
||||||
@ -294,28 +385,49 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="category_ids[]"
|
name="category_ids[]"
|
||||||
value={category.id}
|
value={category.id}
|
||||||
checked={categoryIds.includes(category.id)}
|
checked={categoryIds.includes(
|
||||||
|
category.id,
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCategoryIds((prev) =>
|
setCategoryIds(
|
||||||
e.target.checked
|
(prev) =>
|
||||||
? [...prev, category.id]
|
e.target
|
||||||
: prev.filter((id) => id !== category.id)
|
.checked
|
||||||
|
? [
|
||||||
|
...prev,
|
||||||
|
category.id,
|
||||||
|
]
|
||||||
|
: prev.filter(
|
||||||
|
(
|
||||||
|
id,
|
||||||
|
) =>
|
||||||
|
id !==
|
||||||
|
category.id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span>{category.name}</span>
|
<span>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<InputError message={errors.category_ids} />
|
<InputError
|
||||||
|
message={errors.category_ids}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="description">Deskripsi</Label>
|
<Label htmlFor="description">
|
||||||
|
Deskripsi
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
defaultValue={product.description ?? ''}
|
defaultValue={
|
||||||
|
product.description ?? ''
|
||||||
|
}
|
||||||
placeholder="Masukkan deskripsi produk"
|
placeholder="Masukkan deskripsi produk"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@ -331,31 +443,80 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="use_same_price"
|
name="use_same_price"
|
||||||
value={useSamePrice ? '1' : '0'}
|
value={useSamePrice ? '1' : '0'}
|
||||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
onValueChange={(val) =>
|
||||||
|
setUseSamePrice(val === '1')
|
||||||
|
}
|
||||||
className="flex gap-6"
|
className="flex gap-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="1" id="price-same" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
value="1"
|
||||||
|
id="price-same"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="price-same"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Semua varian sama
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="0" id="price-different" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
value="0"
|
||||||
|
id="price-different"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="price-different"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Harga per varian
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
{useSamePrice && (
|
{useSamePrice && (
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
{PRICE_TYPES.map((priceType, priceIndex) => (
|
{PRICE_TYPES.map(
|
||||||
<div key={priceType.key} className="grid gap-2">
|
(priceType, priceIndex) => (
|
||||||
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
|
<div
|
||||||
<RupiahInput
|
key={priceType.key}
|
||||||
value={sharedPrices[priceIndex]?.price ?? 0}
|
className="grid gap-2"
|
||||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
>
|
||||||
/>
|
<Label>
|
||||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
Harga{' '}
|
||||||
</div>
|
{
|
||||||
))}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -366,126 +527,314 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
<CardTitle>Varian Produk</CardTitle>
|
<CardTitle>Varian Produk</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{variants.map((variant, variantIndex) => (
|
{variants.map(
|
||||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
(variant, variantIndex) => (
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
key={variantIndex}
|
||||||
<div className="flex items-center gap-1">
|
className="space-y-4 rounded-lg border p-4"
|
||||||
{!useSamePrice && (
|
>
|
||||||
<>
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => copyPrices(variantIndex)}
|
onClick={() =>
|
||||||
|
removeVariant(
|
||||||
|
variantIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{copiedIndex === variantIndex ? (
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Salin
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
</div>
|
||||||
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>
|
||||||
<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>
|
<Button
|
||||||
<Input
|
type="button"
|
||||||
value={variant.name}
|
variant="outline"
|
||||||
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)}
|
onClick={addVariant}
|
||||||
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}>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Tambah Varian
|
Tambah Varian
|
||||||
</Button>
|
</Button>
|
||||||
@ -493,13 +842,19 @@ export default function ProductEdit({ product, categories }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
processing ||
|
||||||
|
variants.some((v) => v.uploading)
|
||||||
|
}
|
||||||
|
>
|
||||||
{processing
|
{processing
|
||||||
? 'Menyimpan...'
|
? 'Menyimpan...'
|
||||||
: variants.some((v) => v.uploading)
|
: variants.some((v) => v.uploading)
|
||||||
? 'Mengunggah...'
|
? 'Mengunggah...'
|
||||||
: 'Simpan'}
|
: 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -7,9 +7,26 @@ import type { PaginationState, SortState } from '@/components/data-table';
|
|||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
|
import {
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
Combobox,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -18,7 +35,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} 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 type { Product } from './columns';
|
||||||
import { createProductColumns } 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 allVariants = row.original.product_variants ?? [];
|
||||||
const query = (searchValue ?? '').toLowerCase().trim();
|
const query = (searchValue ?? '').toLowerCase().trim();
|
||||||
const variants = query
|
const variants = query
|
||||||
@ -95,7 +124,10 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{variants.length === 0 ? (
|
{variants.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
Tidak ada varian.
|
Tidak ada varian.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -114,21 +146,36 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{variant.name}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell className="text-center">{formatNumber(variant.stock)}</TableCell>
|
{variant.name}
|
||||||
<TableCell className="text-center">{formatNumber(variant.reject_stock)}</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">{formatNumber(variant.retail_stock)}</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>
|
<TableCell>
|
||||||
{variant.product_prices?.length > 0 ? (
|
{variant.product_prices?.length > 0 ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{variant.product_prices.map((p) => (
|
{variant.product_prices.map((p) => (
|
||||||
<span key={p.id} className="text-xs">
|
<span
|
||||||
<span className="text-muted-foreground">{p.type_label}:</span>{' '}
|
key={p.id}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{p.type_label}:
|
||||||
|
</span>{' '}
|
||||||
{formatCurrency(p.price)}
|
{formatCurrency(p.price)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : '-'}
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -142,7 +189,10 @@ export default function ProductIndex({ products, filters }: Props) {
|
|||||||
const [deleting, setDeleting] = useState<Product | null>(null);
|
const [deleting, setDeleting] = useState<Product | null>(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
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;
|
const hasActiveFilters = filters.status || filters.name;
|
||||||
|
|
||||||
@ -175,57 +225,80 @@ export default function ProductIndex({ products, filters }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
router.get(productIndex(), {}, {
|
router.get(
|
||||||
preserveState: true,
|
productIndex(),
|
||||||
replace: true,
|
{},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(productIndex.url(), {
|
router.get(
|
||||||
page,
|
productIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
...filters,
|
sort: sort.column,
|
||||||
}, { preserveState: true, replace: true });
|
direction: sort.direction,
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(productIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
productIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
...filters,
|
sort: sort.column,
|
||||||
}, { preserveState: true, replace: true });
|
direction: sort.direction,
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(productIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
productIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
...filters,
|
search: value,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
}, [pagination.per_page, sort, filters]);
|
direction: sort.direction,
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort, filters],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(productIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
productIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
...filters,
|
sort: column,
|
||||||
}, { preserveState: true, replace: true });
|
direction,
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -282,11 +355,18 @@ export default function ProductIndex({ products, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.name ?? ''}
|
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>
|
<ComboboxContent>
|
||||||
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
|
<ComboboxEmpty>
|
||||||
|
Tidak ada produk ditemukan.
|
||||||
|
</ComboboxEmpty>
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{productNames.map((name) => (
|
{productNames.map((name) => (
|
||||||
<ComboboxItem key={name} value={name}>
|
<ComboboxItem key={name} value={name}>
|
||||||
@ -304,15 +384,21 @@ export default function ProductIndex({ products, filters }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.status ?? 'all'}
|
value={filters.status ?? 'all'}
|
||||||
onValueChange={(value) => applyFilter('status', value)}
|
onValueChange={(value) =>
|
||||||
|
applyFilter('status', value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Semua Status" />
|
<SelectValue placeholder="Semua Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Semua Status</SelectItem>
|
<SelectItem value="all">
|
||||||
|
Semua Status
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="active">Aktif</SelectItem>
|
<SelectItem value="active">Aktif</SelectItem>
|
||||||
<SelectItem value="inactive">Non Aktif</SelectItem>
|
<SelectItem value="inactive">
|
||||||
|
Non Aktif
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -354,7 +440,9 @@ export default function ProductIndex({ products, filters }: Props) {
|
|||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
currentSort={sort}
|
currentSort={sort}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
|
renderSubRow={(row, searchValue) => (
|
||||||
|
<VariantSubRow row={row} searchValue={searchValue} />
|
||||||
|
)}
|
||||||
defaultExpanded
|
defaultExpanded
|
||||||
toolbar={filterToolbar}
|
toolbar={filterToolbar}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -30,9 +30,7 @@ export function createSupplierColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -46,9 +44,7 @@ export function createSupplierColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama</span>
|
<span>Nama</span>
|
||||||
@ -68,9 +64,7 @@ export function createSupplierColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>No. Telepon</span>
|
<span>No. Telepon</span>
|
||||||
@ -78,9 +72,7 @@ export function createSupplierColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
|
||||||
{row.getValue('phone_number') as string ?? '-'}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -90,9 +82,7 @@ export function createSupplierColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Alamat</span>
|
<span>Alamat</span>
|
||||||
@ -100,8 +90,8 @@ export function createSupplierColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="max-w-[200px] truncate block">
|
<span className="block max-w-[200px] truncate">
|
||||||
{row.getValue('address') as string ?? '-'}
|
{(row.getValue('address') as string) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -123,16 +113,12 @@ export function createSupplierColumns(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => handleEdit(supplier)}
|
||||||
handleEdit(supplier)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -16,7 +16,12 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 type { Supplier } from './columns';
|
||||||
import { createSupplierColumns } from './columns';
|
import { createSupplierColumns } from './columns';
|
||||||
|
|
||||||
@ -35,7 +40,10 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
const [editing, setEditing] = useState<Supplier | null>(null);
|
const [editing, setEditing] = useState<Supplier | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: suppliers.current_page,
|
current_page: suppliers.current_page,
|
||||||
@ -45,57 +53,76 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(supplierIndex.url(), {
|
router.get(
|
||||||
page,
|
supplierIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(supplierIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
supplierIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, {
|
sort: sort.column,
|
||||||
preserveState: true,
|
direction: sort.direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(supplierIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
supplierIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, {
|
search: value,
|
||||||
preserveState: true,
|
sort: sort.column,
|
||||||
replace: true,
|
direction: sort.direction,
|
||||||
});
|
},
|
||||||
}, [pagination.per_page, sort]);
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(supplierIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
supplierIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, {
|
sort: column,
|
||||||
preserveState: true,
|
direction,
|
||||||
replace: true,
|
},
|
||||||
});
|
{
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -135,32 +162,46 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
<Form
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tambah Supplier</DialogTitle>
|
<DialogTitle>
|
||||||
|
Tambah Supplier
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama{' '} <span className="text-destructive">*</span>
|
Nama{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama supplier"
|
placeholder="Masukkan nama supplier"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone_number">
|
<Label htmlFor="phone_number">
|
||||||
No. Telepon
|
No. Telepon
|
||||||
</Label>
|
</Label>
|
||||||
<PhoneNumberInput name="phone_number"/>
|
<PhoneNumberInput name="phone_number" />
|
||||||
<InputError message={errors.phone_number} />
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.phone_number
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="address">
|
<Label htmlFor="address">
|
||||||
@ -171,19 +212,23 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCreateOpen(false)}
|
onClick={() =>
|
||||||
|
setCreateOpen(false)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -224,27 +269,43 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
<Form
|
||||||
|
action={update(editing.id)}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => setEditing(null)}
|
||||||
|
>
|
||||||
{({ errors, processing }) => {
|
{({ errors, processing }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Supplier</DialogTitle>
|
<DialogTitle>
|
||||||
|
Edit Supplier
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<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
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Masukkan nama supplier"
|
placeholder="Masukkan nama supplier"
|
||||||
defaultValue={editing.name}
|
defaultValue={
|
||||||
|
editing.name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.name}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.name} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-phone_number">No. Telepon</Label>
|
<Label htmlFor="edit-phone_number">
|
||||||
|
No. Telepon
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-phone_number"
|
id="edit-phone_number"
|
||||||
name="phone_number"
|
name="phone_number"
|
||||||
@ -252,19 +313,33 @@ export default function SupplierIndex({ suppliers }: Props) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
placeholder="Masukkan nomor telepon"
|
placeholder="Masukkan nomor telepon"
|
||||||
defaultValue={editing.phone_number ?? ''}
|
defaultValue={
|
||||||
|
editing.phone_number ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.phone_number
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.phone_number} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-address">Alamat</Label>
|
<Label htmlFor="edit-address">
|
||||||
|
Alamat
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-address"
|
id="edit-address"
|
||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
defaultValue={editing.address ?? ''}
|
defaultValue={
|
||||||
|
editing.address ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -29,9 +29,7 @@ export function createRoleColumns(
|
|||||||
id: 'no',
|
id: 'no',
|
||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">{row.index + 1}</span>
|
||||||
{row.index + 1}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[50px] text-center',
|
className: 'w-[50px] text-center',
|
||||||
@ -45,9 +43,7 @@ export function createRoleColumns(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="-ml-3 h-8"
|
className="-ml-3 h-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
column.toggleSorting(
|
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||||
column.getIsSorted() === 'asc',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>Nama Role</span>
|
<span>Nama Role</span>
|
||||||
@ -62,7 +58,9 @@ export function createRoleColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'permissions_count',
|
accessorKey: 'permissions_count',
|
||||||
header: () => <span className="block text-center">Jumlah Permission</span>,
|
header: () => (
|
||||||
|
<span className="block text-center">Jumlah Permission</span>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-[180px] text-center',
|
className: 'w-[180px] text-center',
|
||||||
headerClassName: 'w-[180px] text-center',
|
headerClassName: 'w-[180px] text-center',
|
||||||
@ -96,9 +94,7 @@ export function createRoleColumns(
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">Edit</TooltipContent>
|
||||||
Edit
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -15,41 +15,41 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moduleLabels: Record<string, string> = {
|
const moduleLabels: Record<string, string> = {
|
||||||
'user': 'Pengguna',
|
user: 'Pengguna',
|
||||||
'category': 'Kategori',
|
category: 'Kategori',
|
||||||
'supplier': 'Supplier',
|
supplier: 'Supplier',
|
||||||
'customer': 'Customer',
|
customer: 'Customer',
|
||||||
'cash-account': 'Kas Toko',
|
'cash-account': 'Kas Toko',
|
||||||
'expense': 'Pengeluaran',
|
expense: 'Pengeluaran',
|
||||||
'employee-advance': 'Kasbon',
|
'employee-advance': 'Kasbon',
|
||||||
'payroll-period': 'Periode Gaji',
|
'payroll-period': 'Periode Gaji',
|
||||||
'payroll': 'Gaji',
|
payroll: 'Gaji',
|
||||||
'payroll-adjustment': 'Adjustment Gaji',
|
'payroll-adjustment': 'Adjustment Gaji',
|
||||||
'leave-request': 'Cuti',
|
'leave-request': 'Cuti',
|
||||||
'attendance': 'Absensi',
|
attendance: 'Absensi',
|
||||||
'settings': 'Pengaturan',
|
settings: 'Pengaturan',
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionLabels: Record<string, string> = {
|
const actionLabels: Record<string, string> = {
|
||||||
'view': 'Lihat',
|
view: 'Lihat',
|
||||||
'create': 'Tambah',
|
create: 'Tambah',
|
||||||
'update': 'Edit',
|
update: 'Edit',
|
||||||
'delete': 'Hapus',
|
delete: 'Hapus',
|
||||||
'toggle-active': 'Aktif/Nonaktif',
|
'toggle-active': 'Aktif/Nonaktif',
|
||||||
'reset-password': 'Reset Kata Sandi',
|
'reset-password': 'Reset Kata Sandi',
|
||||||
'deposit': 'Setor',
|
deposit: 'Setor',
|
||||||
'withdrawal': 'Tarik',
|
withdrawal: 'Tarik',
|
||||||
'approve': 'Setujui',
|
approve: 'Setujui',
|
||||||
'pay': 'Bayar',
|
pay: 'Bayar',
|
||||||
'reject': 'Tolak',
|
reject: 'Tolak',
|
||||||
'current': 'Periode Saat Ini',
|
current: 'Periode Saat Ini',
|
||||||
'close': 'Tutup',
|
close: 'Tutup',
|
||||||
'reopen': 'Buka Kembali',
|
reopen: 'Buka Kembali',
|
||||||
'cancel': 'Batalkan',
|
cancel: 'Batalkan',
|
||||||
'check-in': 'Check In',
|
'check-in': 'Check In',
|
||||||
'check-out': 'Check Out',
|
'check-out': 'Check Out',
|
||||||
'by-date': 'Lihat Per Tanggal',
|
'by-date': 'Lihat Per Tanggal',
|
||||||
'show': 'Detail',
|
show: 'Detail',
|
||||||
'update-system': 'Update Sistem',
|
'update-system': 'Update Sistem',
|
||||||
'update-homepage': 'Update Homepage',
|
'update-homepage': 'Update Homepage',
|
||||||
'update-social-media': 'Update Media Sosial',
|
'update-social-media': 'Update Media Sosial',
|
||||||
@ -88,7 +88,10 @@ export default function RoleCreate({ permissions }: Props) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Role <span className="text-destructive">*</span>
|
Nama Role{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@ -105,35 +108,48 @@ export default function RoleCreate({ permissions }: Props) {
|
|||||||
<CardTitle>Permission</CardTitle>
|
<CardTitle>Permission</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-6">
|
<CardContent className="grid gap-6">
|
||||||
{Object.entries(permissions).map(([module, actions]) => (
|
{Object.entries(permissions).map(
|
||||||
<div key={module} className="grid gap-3">
|
([module, actions]) => (
|
||||||
<Label className="text-sm font-semibold">
|
<div
|
||||||
{moduleLabels[module] ?? module}
|
key={module}
|
||||||
</Label>
|
className="grid gap-3"
|
||||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
>
|
||||||
{actions.map((action) => (
|
<Label className="text-sm font-semibold">
|
||||||
<label
|
{moduleLabels[module] ??
|
||||||
key={`${module}.${action}`}
|
module}
|
||||||
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
|
</Label>
|
||||||
>
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
<Checkbox
|
{actions.map(
|
||||||
name="permissions[]"
|
(action) => (
|
||||||
value={`${module}.${action}`}
|
<label
|
||||||
/>
|
key={`${module}.${action}`}
|
||||||
<span className="text-xs">
|
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
|
||||||
{actionLabels[action] ?? action}
|
>
|
||||||
</span>
|
<Checkbox
|
||||||
</label>
|
name="permissions[]"
|
||||||
))}
|
value={`${module}.${action}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{actionLabels[
|
||||||
|
action
|
||||||
|
] ??
|
||||||
|
action}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
))}
|
)}
|
||||||
<InputError message={errors.permissions} />
|
<InputError
|
||||||
|
message={errors.permissions}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -25,41 +25,41 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moduleLabels: Record<string, string> = {
|
const moduleLabels: Record<string, string> = {
|
||||||
'user': 'Pengguna',
|
user: 'Pengguna',
|
||||||
'category': 'Kategori',
|
category: 'Kategori',
|
||||||
'supplier': 'Supplier',
|
supplier: 'Supplier',
|
||||||
'customer': 'Customer',
|
customer: 'Customer',
|
||||||
'cash-account': 'Kas Toko',
|
'cash-account': 'Kas Toko',
|
||||||
'expense': 'Pengeluaran',
|
expense: 'Pengeluaran',
|
||||||
'employee-advance': 'Kasbon',
|
'employee-advance': 'Kasbon',
|
||||||
'payroll-period': 'Periode Gaji',
|
'payroll-period': 'Periode Gaji',
|
||||||
'payroll': 'Gaji',
|
payroll: 'Gaji',
|
||||||
'payroll-adjustment': 'Adjustment Gaji',
|
'payroll-adjustment': 'Adjustment Gaji',
|
||||||
'leave-request': 'Cuti',
|
'leave-request': 'Cuti',
|
||||||
'attendance': 'Absensi',
|
attendance: 'Absensi',
|
||||||
'settings': 'Pengaturan',
|
settings: 'Pengaturan',
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionLabels: Record<string, string> = {
|
const actionLabels: Record<string, string> = {
|
||||||
'view': 'Lihat',
|
view: 'Lihat',
|
||||||
'create': 'Tambah',
|
create: 'Tambah',
|
||||||
'update': 'Edit',
|
update: 'Edit',
|
||||||
'delete': 'Hapus',
|
delete: 'Hapus',
|
||||||
'toggle-active': 'Aktif/Nonaktif',
|
'toggle-active': 'Aktif/Nonaktif',
|
||||||
'reset-password': 'Reset Kata Sandi',
|
'reset-password': 'Reset Kata Sandi',
|
||||||
'deposit': 'Setor',
|
deposit: 'Setor',
|
||||||
'withdrawal': 'Tarik',
|
withdrawal: 'Tarik',
|
||||||
'approve': 'Setujui',
|
approve: 'Setujui',
|
||||||
'pay': 'Bayar',
|
pay: 'Bayar',
|
||||||
'reject': 'Tolak',
|
reject: 'Tolak',
|
||||||
'current': 'Periode Saat Ini',
|
current: 'Periode Saat Ini',
|
||||||
'close': 'Tutup',
|
close: 'Tutup',
|
||||||
'reopen': 'Buka Kembali',
|
reopen: 'Buka Kembali',
|
||||||
'cancel': 'Batalkan',
|
cancel: 'Batalkan',
|
||||||
'check-in': 'Check In',
|
'check-in': 'Check In',
|
||||||
'check-out': 'Check Out',
|
'check-out': 'Check Out',
|
||||||
'by-date': 'Lihat Per Tanggal',
|
'by-date': 'Lihat Per Tanggal',
|
||||||
'show': 'Detail',
|
show: 'Detail',
|
||||||
'update-system': 'Update Sistem',
|
'update-system': 'Update Sistem',
|
||||||
'update-homepage': 'Update Homepage',
|
'update-homepage': 'Update Homepage',
|
||||||
'update-social-media': 'Update Media Sosial',
|
'update-social-media': 'Update Media Sosial',
|
||||||
@ -100,7 +100,10 @@ export default function RoleEdit({ role, permissions }: Props) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Nama Role <span className="text-destructive">*</span>
|
Nama Role{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@ -118,36 +121,51 @@ export default function RoleEdit({ role, permissions }: Props) {
|
|||||||
<CardTitle>Permission</CardTitle>
|
<CardTitle>Permission</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-6">
|
<CardContent className="grid gap-6">
|
||||||
{Object.entries(permissions).map(([module, actions]) => (
|
{Object.entries(permissions).map(
|
||||||
<div key={module} className="grid gap-3">
|
([module, actions]) => (
|
||||||
<Label className="text-sm font-semibold">
|
<div
|
||||||
{moduleLabels[module] ?? module}
|
key={module}
|
||||||
</Label>
|
className="grid gap-3"
|
||||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
>
|
||||||
{actions.map((action) => (
|
<Label className="text-sm font-semibold">
|
||||||
<label
|
{moduleLabels[module] ??
|
||||||
key={`${module}.${action}`}
|
module}
|
||||||
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
|
</Label>
|
||||||
>
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
<Checkbox
|
{actions.map(
|
||||||
name="permissions[]"
|
(action) => (
|
||||||
value={`${module}.${action}`}
|
<label
|
||||||
defaultChecked={assignedPermissions.includes(`${module}.${action}`)}
|
key={`${module}.${action}`}
|
||||||
/>
|
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
|
||||||
<span className="text-xs">
|
>
|
||||||
{actionLabels[action] ?? action}
|
<Checkbox
|
||||||
</span>
|
name="permissions[]"
|
||||||
</label>
|
value={`${module}.${action}`}
|
||||||
))}
|
defaultChecked={assignedPermissions.includes(
|
||||||
|
`${module}.${action}`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{actionLabels[
|
||||||
|
action
|
||||||
|
] ??
|
||||||
|
action}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
))}
|
)}
|
||||||
<InputError message={errors.permissions} />
|
<InputError
|
||||||
|
message={errors.permissions}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -2,12 +2,17 @@ import { Head, router } from '@inertiajs/react';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type {PaginationState, SortState} from '@/components/data-table';
|
import type { PaginationState, SortState } from '@/components/data-table';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
||||||
import { createRoleColumns } from './columns';
|
index as rolesIndex,
|
||||||
import type {Role} from './columns';
|
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 = {
|
type Props = {
|
||||||
roles: {
|
roles: {
|
||||||
@ -22,7 +27,10 @@ type Props = {
|
|||||||
export default function RoleIndex({ roles }: Props) {
|
export default function RoleIndex({ roles }: Props) {
|
||||||
const [deleting, setDeleting] = useState<Role | null>(null);
|
const [deleting, setDeleting] = useState<Role | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: roles.current_page,
|
current_page: roles.current_page,
|
||||||
@ -32,45 +40,64 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
router.get(rolesIndex.url(), {
|
router.get(
|
||||||
page,
|
rolesIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page,
|
||||||
sort: sort.column,
|
per_page: pagination.per_page,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePerPageChange(perPage: number) {
|
function handlePerPageChange(perPage: number) {
|
||||||
router.get(rolesIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
rolesIndex.url(),
|
||||||
per_page: perPage,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: sort.column,
|
per_page: perPage,
|
||||||
direction: sort.direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback(
|
||||||
setSearch(value);
|
(value: string) => {
|
||||||
router.get(rolesIndex.url(), {
|
setSearch(value);
|
||||||
page: 1,
|
router.get(
|
||||||
per_page: pagination.per_page,
|
rolesIndex.url(),
|
||||||
search: value,
|
{
|
||||||
sort: sort.column,
|
page: 1,
|
||||||
direction: sort.direction,
|
per_page: pagination.per_page,
|
||||||
}, { preserveState: true, replace: true });
|
search: value,
|
||||||
}, [pagination.per_page, sort]);
|
sort: sort.column,
|
||||||
|
direction: sort.direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pagination.per_page, sort],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||||
setSort({ column, direction });
|
setSort({ column, direction });
|
||||||
router.get(rolesIndex.url(), {
|
router.get(
|
||||||
page: 1,
|
rolesIndex.url(),
|
||||||
per_page: pagination.per_page,
|
{
|
||||||
search,
|
page: 1,
|
||||||
sort: column,
|
per_page: pagination.per_page,
|
||||||
direction,
|
search,
|
||||||
}, { preserveState: true, replace: true });
|
sort: column,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
|
|||||||
@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Form, Head } from '@inertiajs/react';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@ -60,11 +66,17 @@ type HomepageSettingsTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function HomepageSettingsTab({ homepage }: 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 [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 [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);
|
const [galleryUploading, setGalleryUploading] = useState(false);
|
||||||
|
|
||||||
function addGalleryImage() {
|
function addGalleryImage() {
|
||||||
@ -76,7 +88,9 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateGalleryKey(index: number, key: string | null) {
|
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 (
|
return (
|
||||||
@ -88,10 +102,23 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
|||||||
<CardTitle>Homepage</CardTitle>
|
<CardTitle>Homepage</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4">
|
<CardContent className="grid grid-cols-1 gap-4">
|
||||||
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
|
<input
|
||||||
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
|
type="hidden"
|
||||||
|
name="hero_image_key"
|
||||||
|
value={heroKey ?? ''}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="about_image_key"
|
||||||
|
value={aboutKey ?? ''}
|
||||||
|
/>
|
||||||
{galleryKeys.map((key, index) => (
|
{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">
|
<div className="grid gap-2">
|
||||||
@ -133,14 +160,28 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{galleryKeys.map((key, index) => (
|
{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">
|
<div className="flex-1">
|
||||||
<FileUpload
|
<FileUpload
|
||||||
value={key || null}
|
value={key || null}
|
||||||
onChange={(k) => updateGalleryKey(index, k)}
|
onChange={(k) =>
|
||||||
|
updateGalleryKey(
|
||||||
|
index,
|
||||||
|
k,
|
||||||
|
)
|
||||||
|
}
|
||||||
folder="homepage/gallery"
|
folder="homepage/gallery"
|
||||||
existingUrl={homepage.gallery_images[index] ?? null}
|
existingUrl={
|
||||||
onUploadingChange={setGalleryUploading}
|
homepage.gallery_images[
|
||||||
|
index
|
||||||
|
] ?? null
|
||||||
|
}
|
||||||
|
onUploadingChange={
|
||||||
|
setGalleryUploading
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -148,22 +189,37 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mt-1 text-destructive hover:text-destructive"
|
className="mt-1 text-destructive hover:text-destructive"
|
||||||
onClick={() => removeGalleryImage(index)}
|
onClick={() =>
|
||||||
|
removeGalleryImage(index)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{galleryKeys.length === 0 && (
|
{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>
|
</div>
|
||||||
<InputError message={errors.gallery_image_keys} />
|
<InputError
|
||||||
|
message={errors.gallery_image_keys}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex items-center gap-4">
|
<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'}
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -183,43 +239,103 @@ const sidebarTabs = [
|
|||||||
|
|
||||||
type TabKey = (typeof sidebarTabs)[number]['key'];
|
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);
|
const [type, setType] = useState(data.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4">
|
<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 flex-1 grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground md:hidden">Dasar</span>
|
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||||
<RadioGroup name={`${prefix}[base]`} defaultValue={data.base} className="flex gap-4">
|
Dasar
|
||||||
|
</span>
|
||||||
|
<RadioGroup
|
||||||
|
name={`${prefix}[base]`}
|
||||||
|
defaultValue={data.base}
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="per_transaksi" id={`${prefix}-base-per_transaksi`} />
|
<RadioGroupItem
|
||||||
<Label htmlFor={`${prefix}-base-per_transaksi`} className="font-normal text-xs">Per Transaksi</Label>
|
value="per_transaksi"
|
||||||
|
id={`${prefix}-base-per_transaksi`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}-base-per_transaksi`}
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
Per Transaksi
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="per_produk" id={`${prefix}-base-per_produk`} />
|
<RadioGroupItem
|
||||||
<Label htmlFor={`${prefix}-base-per_produk`} className="font-normal text-xs">Per Produk</Label>
|
value="per_produk"
|
||||||
|
id={`${prefix}-base-per_produk`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}-base-per_produk`}
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
Per Produk
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground md:hidden">Tipe</span>
|
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||||
<RadioGroup name={`${prefix}[type]`} defaultValue={data.type} onValueChange={setType} className="flex gap-4">
|
Tipe
|
||||||
|
</span>
|
||||||
|
<RadioGroup
|
||||||
|
name={`${prefix}[type]`}
|
||||||
|
defaultValue={data.type}
|
||||||
|
onValueChange={setType}
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="flat" id={`${prefix}-type-flat`} />
|
<RadioGroupItem
|
||||||
<Label htmlFor={`${prefix}-type-flat`} className="font-normal text-xs">Flat</Label>
|
value="flat"
|
||||||
|
id={`${prefix}-type-flat`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}-type-flat`}
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
Flat
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="persentase" id={`${prefix}-type-persentase`} />
|
<RadioGroupItem
|
||||||
<Label htmlFor={`${prefix}-type-persentase`} className="font-normal text-xs">Persentase</Label>
|
value="persentase"
|
||||||
|
id={`${prefix}-type-persentase`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}-type-persentase`}
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
Persentase
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<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' ? (
|
{type === 'flat' ? (
|
||||||
<RupiahInput name={`${prefix}[value]`} defaultValue={data.value} />
|
<RupiahInput
|
||||||
|
name={`${prefix}[value]`}
|
||||||
|
defaultValue={data.value}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
name={`${prefix}[value]`}
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<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 [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 = [
|
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 Platform',
|
||||||
{ label: 'Komisi Dinamis', prefix: 'tiktok_shop_dynamic_commission', data: marketplace.tiktok_shop.dynamic_commission },
|
prefix: 'tiktok_shop_platform_commission',
|
||||||
{ label: 'Pemrosesan Pesanan', prefix: 'tiktok_shop_order_processing_fee', data: marketplace.tiktok_shop.order_processing_fee },
|
data: marketplace.tiktok_shop.platform_commission,
|
||||||
{ 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: '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 = [
|
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: 'Biaya Administrasi',
|
||||||
{ label: 'Hemat Biaya Kirim', prefix: 'shopee_shipping_savings', data: marketplace.shopee.shipping_savings },
|
prefix: 'shopee_admin_fee',
|
||||||
{ label: 'Premi', prefix: 'shopee_premium', data: marketplace.shopee.premium },
|
data: marketplace.shopee.admin_fee,
|
||||||
{ 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: 'Biaya Program',
|
||||||
{ label: 'PO', prefix: 'shopee_pre_order', data: marketplace.shopee.pre_order },
|
prefix: 'shopee_program_fee',
|
||||||
{ label: 'Live Extra', prefix: 'shopee_live_extra', data: marketplace.shopee.live_extra },
|
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 (
|
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">
|
<div className="flex flex-col lg:flex-row lg:space-x-12">
|
||||||
<aside className="w-full max-w-xl lg:w-48">
|
<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) => (
|
{sidebarTabs.map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab.key}
|
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">
|
<section className="w-full min-w-0 flex-1 space-y-6">
|
||||||
{activeTab === 'sistem' && (
|
{activeTab === 'sistem' && (
|
||||||
<Form action={updateSystem()} options={{ preserveScroll: true }}>
|
<Form
|
||||||
|
action={updateSystem()}
|
||||||
|
options={{ preserveScroll: true }}
|
||||||
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="app_name">
|
<Label htmlFor="app_name">
|
||||||
Nama Aplikasi <span className="text-destructive">*</span>
|
Nama Aplikasi{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="app_name" name="app_name" placeholder="Masukkan nama aplikasi" defaultValue={system.app_name} />
|
<Input
|
||||||
<InputError message={errors.app_name} />
|
id="app_name"
|
||||||
|
name="app_name"
|
||||||
|
placeholder="Masukkan nama aplikasi"
|
||||||
|
defaultValue={
|
||||||
|
system.app_name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.app_name
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">
|
||||||
<Input id="email" name="email" type="email" placeholder="Masukkan email" defaultValue={system.email} />
|
Email
|
||||||
<InputError message={errors.email} />
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Masukkan email"
|
||||||
|
defaultValue={
|
||||||
|
system.email
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.email}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone">No. Telepon</Label>
|
<Label htmlFor="phone">
|
||||||
<PhoneNumberInput name="phone" defaultValue={system.phone} />
|
No. Telepon
|
||||||
<InputError message={errors.phone} />
|
</Label>
|
||||||
|
<PhoneNumberInput
|
||||||
|
name="phone"
|
||||||
|
defaultValue={
|
||||||
|
system.phone
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.phone}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="address">Alamat</Label>
|
<Label htmlFor="address">
|
||||||
<Textarea id="address" name="address" placeholder="Masukkan alamat" rows={3} defaultValue={system.address} />
|
Alamat
|
||||||
<InputError message={errors.address} />
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
placeholder="Masukkan alamat"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={
|
||||||
|
system.address
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={errors.address}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="about_app">Tentang Aplikasi</Label>
|
<Label htmlFor="about_app">
|
||||||
<Textarea id="about_app" name="about_app" placeholder="Masukkan deskripsi aplikasi" rows={4} defaultValue={system.about_app} />
|
Tentang Aplikasi
|
||||||
<InputError message={errors.about_app} />
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="about_app"
|
||||||
|
name="about_app"
|
||||||
|
placeholder="Masukkan deskripsi aplikasi"
|
||||||
|
rows={4}
|
||||||
|
defaultValue={
|
||||||
|
system.about_app
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.about_app
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -376,33 +640,87 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'media-sosial' && (
|
{activeTab === 'media-sosial' && (
|
||||||
<Form action={updateSocialMedia()} options={{ preserveScroll: true }}>
|
<Form
|
||||||
|
action={updateSocialMedia()}
|
||||||
|
options={{ preserveScroll: true }}
|
||||||
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Media Sosial</CardTitle>
|
<CardTitle>
|
||||||
|
Media Sosial
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="instagram_url">Instagram</Label>
|
<Label htmlFor="instagram_url">
|
||||||
<Input id="instagram_url" name="instagram_url" placeholder="https://instagram.com/..." defaultValue={socialMedia.instagram_url ?? ''} />
|
Instagram
|
||||||
<InputError message={errors.instagram_url} />
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="instagram_url"
|
||||||
|
name="instagram_url"
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
defaultValue={
|
||||||
|
socialMedia.instagram_url ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.instagram_url
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="facebook_url">Facebook</Label>
|
<Label htmlFor="facebook_url">
|
||||||
<Input id="facebook_url" name="facebook_url" placeholder="https://facebook.com/..." defaultValue={socialMedia.facebook_url ?? ''} />
|
Facebook
|
||||||
<InputError message={errors.facebook_url} />
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="facebook_url"
|
||||||
|
name="facebook_url"
|
||||||
|
placeholder="https://facebook.com/..."
|
||||||
|
defaultValue={
|
||||||
|
socialMedia.facebook_url ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.facebook_url
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="tiktok_url">TikTok</Label>
|
<Label htmlFor="tiktok_url">
|
||||||
<Input id="tiktok_url" name="tiktok_url" placeholder="https://tiktok.com/..." defaultValue={socialMedia.tiktok_url ?? ''} />
|
TikTok
|
||||||
<InputError message={errors.tiktok_url} />
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tiktok_url"
|
||||||
|
name="tiktok_url"
|
||||||
|
placeholder="https://tiktok.com/..."
|
||||||
|
defaultValue={
|
||||||
|
socialMedia.tiktok_url ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.tiktok_url
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -410,23 +728,58 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'marketplace' && (
|
{activeTab === 'marketplace' && (
|
||||||
<Form action={updateMarketplace()} options={{ preserveScroll: true }}>
|
<Form
|
||||||
|
action={updateMarketplace()}
|
||||||
|
options={{ preserveScroll: true }}
|
||||||
|
>
|
||||||
{({ processing }) => (
|
{({ processing }) => (
|
||||||
<div className="grid gap-6">
|
<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">
|
<TabsList className="mb-3">
|
||||||
<TabsTrigger value="tiktok-shop">TikTok Shop</TabsTrigger>
|
<TabsTrigger value="tiktok-shop">
|
||||||
<TabsTrigger value="shopee">Shopee</TabsTrigger>
|
TikTok Shop
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="shopee">
|
||||||
|
Shopee
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div hidden={marketplaceTab !== 'tiktok-shop'}>
|
<div
|
||||||
<MarketplaceCard title="TikTok Shop" variables={tiktokShopVariables} />
|
hidden={
|
||||||
|
marketplaceTab !== 'tiktok-shop'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MarketplaceCard
|
||||||
|
title="TikTok Shop"
|
||||||
|
variables={tiktokShopVariables}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div hidden={marketplaceTab !== 'shopee'}>
|
<div
|
||||||
<MarketplaceCard title="Shopee" variables={shopeeVariables} />
|
hidden={marketplaceTab !== 'shopee'}
|
||||||
|
>
|
||||||
|
<MarketplaceCard
|
||||||
|
title="Shopee"
|
||||||
|
variables={shopeeVariables}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -434,7 +787,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'hr' && (
|
{activeTab === 'hr' && (
|
||||||
<Form action={updateHr()} options={{ preserveScroll: true }}>
|
<Form
|
||||||
|
action={updateHr()}
|
||||||
|
options={{ preserveScroll: true }}
|
||||||
|
>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<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">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="scheduled_check_in_time">
|
<Label htmlFor="scheduled_check_in_time">
|
||||||
Jam Masuk Kerja <span className="text-destructive">*</span>
|
Jam Masuk Kerja{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="scheduled_check_in_time" name="scheduled_check_in_time" type="time" defaultValue={hr.scheduled_check_in_time} />
|
<Input
|
||||||
<InputError message={errors.scheduled_check_in_time} />
|
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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="scheduled_check_out_time">
|
<Label htmlFor="scheduled_check_out_time">
|
||||||
Jam Pulang Kerja <span className="text-destructive">*</span>
|
Jam Pulang Kerja{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="scheduled_check_out_time" name="scheduled_check_out_time" type="time" defaultValue={hr.scheduled_check_out_time} />
|
<Input
|
||||||
<InputError message={errors.scheduled_check_out_time} />
|
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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Denda Keterlambatan <span className="text-destructive">*</span>
|
Denda Keterlambatan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="late_penalty_amount" defaultValue={hr.late_penalty_amount} />
|
<RupiahInput
|
||||||
<InputError message={errors.late_penalty_amount} />
|
name="late_penalty_amount"
|
||||||
|
defaultValue={
|
||||||
|
hr.late_penalty_amount
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.late_penalty_amount
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
Denda Bolos <span className="text-destructive">*</span>
|
Denda Bolos{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RupiahInput name="absent_penalty_amount" defaultValue={hr.absent_penalty_amount} />
|
<RupiahInput
|
||||||
<InputError message={errors.absent_penalty_amount} />
|
name="absent_penalty_amount"
|
||||||
|
defaultValue={
|
||||||
|
hr.absent_penalty_amount
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.absent_penalty_amount
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export default function Login() {
|
|||||||
<Head title="Masuk Akun" />
|
<Head title="Masuk Akun" />
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
action={store()}
|
action={store()}
|
||||||
resetOnSuccess={['password']}
|
resetOnSuccess={['password']}
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
@ -43,7 +43,9 @@ export default function Login() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
Kata Sandi
|
Kata Sandi
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import { useEffect, useState } from 'react';
|
|||||||
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||||
|
|
||||||
const isPWA = () => {
|
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' => {
|
const getPlatform = (): 'android' | 'ios' | 'desktop' => {
|
||||||
@ -36,7 +39,8 @@ const getDeniedMessage = (appName: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Permissions() {
|
export default function Permissions() {
|
||||||
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
|
const [notifications, setNotifications] =
|
||||||
|
useState<PermissionStatus>('unknown');
|
||||||
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
||||||
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
||||||
|
|
||||||
@ -44,24 +48,32 @@ export default function Permissions() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('Notification' in window) {
|
if ('Notification' in window) {
|
||||||
if (Notification.permission === 'granted') setNotifications('granted');
|
if (Notification.permission === 'granted')
|
||||||
else if (Notification.permission === 'denied') setNotifications('denied');
|
setNotifications('granted');
|
||||||
|
else if (Notification.permission === 'denied')
|
||||||
|
setNotifications('denied');
|
||||||
else setNotifications('prompt');
|
else setNotifications('prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigator.mediaDevices) {
|
if (navigator.mediaDevices) {
|
||||||
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
|
navigator.mediaDevices
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
.getUserMedia({ video: true })
|
||||||
setCamera('granted');
|
.then((stream) => {
|
||||||
}).catch(() => {
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
if ('permissions' in navigator) {
|
setCamera('granted');
|
||||||
navigator.permissions.query({ name: 'camera' as PermissionName }).then((result) => {
|
})
|
||||||
setCamera(result.state as PermissionStatus);
|
.catch(() => {
|
||||||
}).catch(() => setCamera('denied'));
|
if ('permissions' in navigator) {
|
||||||
} else {
|
navigator.permissions
|
||||||
setCamera('denied');
|
.query({ name: 'camera' as PermissionName })
|
||||||
}
|
.then((result) => {
|
||||||
});
|
setCamera(result.state as PermissionStatus);
|
||||||
|
})
|
||||||
|
.catch(() => setCamera('denied'));
|
||||||
|
} else {
|
||||||
|
setCamera('denied');
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setCamera('denied');
|
setCamera('denied');
|
||||||
}
|
}
|
||||||
@ -71,9 +83,12 @@ export default function Permissions() {
|
|||||||
() => setLocation('granted'),
|
() => setLocation('granted'),
|
||||||
() => {
|
() => {
|
||||||
if ('permissions' in navigator) {
|
if ('permissions' in navigator) {
|
||||||
navigator.permissions.query({ name: 'geolocation' }).then((result) => {
|
navigator.permissions
|
||||||
setLocation(result.state as PermissionStatus);
|
.query({ name: 'geolocation' })
|
||||||
}).catch(() => setLocation('denied'));
|
.then((result) => {
|
||||||
|
setLocation(result.state as PermissionStatus);
|
||||||
|
})
|
||||||
|
.catch(() => setLocation('denied'));
|
||||||
} else {
|
} else {
|
||||||
setLocation('denied');
|
setLocation('denied');
|
||||||
}
|
}
|
||||||
@ -92,7 +107,13 @@ export default function Permissions() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await Notification.requestPermission();
|
const result = await Notification.requestPermission();
|
||||||
setNotifications(result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'prompt');
|
setNotifications(
|
||||||
|
result === 'granted'
|
||||||
|
? 'granted'
|
||||||
|
: result === 'denied'
|
||||||
|
? 'denied'
|
||||||
|
: 'prompt',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setNotifications('prompt');
|
setNotifications('prompt');
|
||||||
}
|
}
|
||||||
@ -101,7 +122,9 @@ export default function Permissions() {
|
|||||||
const handleCamera = async (checked: boolean) => {
|
const handleCamera = async (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
setCamera('granted');
|
setCamera('granted');
|
||||||
} catch {
|
} catch {
|
||||||
@ -133,13 +156,18 @@ export default function Permissions() {
|
|||||||
<h1 className="sr-only">Izin</h1>
|
<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">
|
<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" />
|
<Bell className="h-4 w-4" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AlertTitle>Notifikasi</AlertTitle>
|
<AlertTitle>Notifikasi</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda.
|
Izinkan aplikasi mengirimkan notifikasi push ke
|
||||||
|
perangkat Anda.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(notifications) && (
|
{isDenied(notifications) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
@ -163,7 +191,8 @@ export default function Permissions() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AlertTitle>Kamera</AlertTitle>
|
<AlertTitle>Kamera</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video.
|
Izinkan aplikasi mengakses kamera perangkat Anda
|
||||||
|
untuk mengambil foto atau video.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(camera) && (
|
{isDenied(camera) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
@ -187,7 +216,8 @@ export default function Permissions() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AlertTitle>Lokasi</AlertTitle>
|
<AlertTitle>Lokasi</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi.
|
Izinkan aplikasi mengakses lokasi perangkat Anda
|
||||||
|
untuk menyediakan layanan berbasis lokasi.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{isDenied(location) && (
|
{isDenied(location) && (
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
|
|||||||
@ -33,7 +33,9 @@ type Props = {
|
|||||||
|
|
||||||
export default function Profile({ user }: Props) {
|
export default function Profile({ user }: Props) {
|
||||||
const [birthDate, setBirthDate] = useState<Date | undefined>(
|
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 (
|
return (
|
||||||
@ -56,7 +58,10 @@ export default function Profile({ user }: Props) {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
Email <span className="text-destructive">*</span>
|
Email{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -69,7 +74,10 @@ export default function Profile({ user }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="username">
|
<Label htmlFor="username">
|
||||||
Username <span className="text-destructive">*</span>
|
Username{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
@ -89,38 +97,71 @@ export default function Profile({ user }: Props) {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="full_name">
|
<Label htmlFor="full_name">
|
||||||
Nama Lengkap <span className="text-destructive">*</span>
|
Nama Lengkap{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="full_name"
|
id="full_name"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
placeholder="Masukkan nama lengkap"
|
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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="phone_number">No. Telepon</Label>
|
<Label htmlFor="phone_number">
|
||||||
|
No. Telepon
|
||||||
|
</Label>
|
||||||
<PhoneNumberInput
|
<PhoneNumberInput
|
||||||
name="phone_number"
|
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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jenis Kelamin</Label>
|
<Label>Jenis Kelamin</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name="gender"
|
name="gender"
|
||||||
defaultValue={user.userProfile?.gender ?? ''}
|
defaultValue={
|
||||||
|
user.userProfile?.gender ?? ''
|
||||||
|
}
|
||||||
className="flex gap-4"
|
className="flex gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="male" id="settings-gender-male" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="settings-gender-male" className="font-normal">Laki-laki</Label>
|
value="male"
|
||||||
|
id="settings-gender-male"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="settings-gender-male"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Laki-laki
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="female" id="settings-gender-female" />
|
<RadioGroupItem
|
||||||
<Label htmlFor="settings-gender-female" className="font-normal">Perempuan</Label>
|
value="female"
|
||||||
|
id="settings-gender-female"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="settings-gender-female"
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
Perempuan
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<InputError message={errors.gender} />
|
<InputError message={errors.gender} />
|
||||||
@ -133,7 +174,9 @@ export default function Profile({ user }: Props) {
|
|||||||
onChange={setBirthDate}
|
onChange={setBirthDate}
|
||||||
placeholder="Pilih tanggal lahir"
|
placeholder="Pilih tanggal lahir"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.birth_date} />
|
<InputError
|
||||||
|
message={errors.birth_date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="address">Alamat</Label>
|
<Label htmlFor="address">Alamat</Label>
|
||||||
@ -142,7 +185,9 @@ export default function Profile({ user }: Props) {
|
|||||||
name="address"
|
name="address"
|
||||||
placeholder="Masukkan alamat"
|
placeholder="Masukkan alamat"
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={user.userProfile?.address ?? ''}
|
defaultValue={
|
||||||
|
user.userProfile?.address ?? ''
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.address} />
|
<InputError message={errors.address} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -45,7 +45,10 @@ export default function Security(props: Props) {
|
|||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="current_password">
|
<Label htmlFor="current_password">
|
||||||
Kata Sandi Saat <span className="text-destructive">*</span>
|
Kata Sandi Saat{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@ -57,12 +60,17 @@ export default function Security(props: Props) {
|
|||||||
placeholder="Masukkan kata sandi saat ini"
|
placeholder="Masukkan kata sandi saat ini"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError message={errors.current_password} />
|
<InputError
|
||||||
|
message={errors.current_password}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
Kata Sandi Baru <span className="text-destructive">*</span>
|
Kata Sandi Baru{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@ -80,7 +88,10 @@ export default function Security(props: Props) {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password_confirmation">
|
<Label htmlFor="password_confirmation">
|
||||||
Konfirmasi Kata Sandi <span className="text-destructive">*</span>
|
Konfirmasi Kata Sandi{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@ -93,7 +104,9 @@ export default function Security(props: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.password_confirmation}
|
message={
|
||||||
|
errors.password_confirmation
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
9
resources/js/types/vite-env.d.ts
vendored
9
resources/js/types/vite-env.d.ts
vendored
@ -5,9 +5,14 @@ declare module 'virtual:pwa-register' {
|
|||||||
immediate?: boolean;
|
immediate?: boolean;
|
||||||
onNeedRefresh?: () => void;
|
onNeedRefresh?: () => void;
|
||||||
onOfflineReady?: () => void;
|
onOfflineReady?: () => void;
|
||||||
onRegisteredSW?: (swUrl: string, registration: ServiceWorkerRegistration | undefined) => void;
|
onRegisteredSW?: (
|
||||||
|
swUrl: string,
|
||||||
|
registration: ServiceWorkerRegistration | undefined,
|
||||||
|
) => void;
|
||||||
onRegisterError?: (error: Error) => void;
|
onRegisterError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => void;
|
export function registerSW(
|
||||||
|
options?: RegisterSWOptions,
|
||||||
|
): (reloadPage?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
use App\Models\ProductPrice;
|
||||||
@ -387,7 +386,7 @@ function allPriceTypes(): array
|
|||||||
'reject_stock' => 2,
|
'reject_stock' => 2,
|
||||||
'retail_stock' => 4,
|
'retail_stock' => 4,
|
||||||
'photo_key' => 'product-variant/v2.jpg',
|
'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,
|
'reject_stock' => 0,
|
||||||
'retail_stock' => 0,
|
'retail_stock' => 0,
|
||||||
'photo_key' => 'product-variant/free.jpg',
|
'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,
|
'reject_stock' => 0,
|
||||||
'retail_stock' => 0,
|
'retail_stock' => 0,
|
||||||
'photo_key' => 'product-variant/expensive.jpg',
|
'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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
|
$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');
|
$response->assertSessionHasErrors('shared_prices.0.price');
|
||||||
@ -1665,7 +1664,7 @@ function allPriceTypes(): array
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
|
$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');
|
$response->assertSessionHasErrors('shared_prices.0.price');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user