Version 2.0
This commit is contained in:
@@ -5,7 +5,6 @@ namespace App\Console\Commands;
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerProduct;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\FeedbackInteraction;
|
||||
use App\Models\Handover;
|
||||
@@ -22,7 +21,7 @@ class ImportCskh extends Command
|
||||
{file_path : Path to CSV/Excel file}
|
||||
{--dry-run : Analyze only, do not write to database}';
|
||||
|
||||
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational';
|
||||
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational (V2)';
|
||||
|
||||
private int $statsProducts = 0;
|
||||
private int $statsCustomers = 0;
|
||||
@@ -123,11 +122,6 @@ class ImportCskh extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isMetaRow(array $row): bool
|
||||
{
|
||||
return $this->getMetaRowReason($row) !== null;
|
||||
}
|
||||
|
||||
private function getMetaRowReason(array $row): ?string
|
||||
{
|
||||
$code = $row['Mã căn hộ'] ?? '';
|
||||
@@ -210,8 +204,6 @@ class ImportCskh extends Command
|
||||
$product = $this->findOrCreateProduct($productCode);
|
||||
$customer = $this->findOrCreateCustomer($customerName);
|
||||
|
||||
$this->ensureCustomerProduct($product, $customer);
|
||||
|
||||
$this->processContracts($row, $product, $customer);
|
||||
$this->processHandover($row, $product, $customer);
|
||||
$this->processServices($row, $product);
|
||||
@@ -250,14 +242,6 @@ class ImportCskh extends Command
|
||||
return $c;
|
||||
}
|
||||
|
||||
private function ensureCustomerProduct(Product $product, Customer $customer): void
|
||||
{
|
||||
CustomerProduct::firstOrCreate([
|
||||
'customer_id' => $customer->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processContracts(array $row, Product $product, Customer $customer): void
|
||||
{
|
||||
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
|
||||
@@ -265,14 +249,16 @@ class ImportCskh extends Command
|
||||
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
|
||||
$exists = Contract::where('product_id', $product->id)
|
||||
->where('customer_id', $customer->id)
|
||||
->where('type', 'lease')
|
||||
->where('category', 'business')
|
||||
->where('type', 'rental')
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
Contract::create([
|
||||
'product_id' => $product->id,
|
||||
'customer_id' => $customer->id,
|
||||
'type' => 'lease',
|
||||
'category' => 'business',
|
||||
'type' => 'rental',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->statsContracts++;
|
||||
@@ -284,6 +270,7 @@ class ImportCskh extends Command
|
||||
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
|
||||
$exists = Contract::where('product_id', $product->id)
|
||||
->where('customer_id', $customer->id)
|
||||
->where('category', 'business')
|
||||
->where('type', 'bcc')
|
||||
->exists();
|
||||
|
||||
@@ -291,6 +278,7 @@ class ImportCskh extends Command
|
||||
Contract::create([
|
||||
'product_id' => $product->id,
|
||||
'customer_id' => $customer->id,
|
||||
'category' => 'business',
|
||||
'type' => 'bcc',
|
||||
'status' => 'active',
|
||||
'signed_status' => $bccStr,
|
||||
@@ -386,20 +374,15 @@ class ImportCskh extends Command
|
||||
|
||||
$title = 'Ticket từ dữ liệu lịch sử';
|
||||
|
||||
$customerProduct = CustomerProduct::where('customer_id', $customer->id)
|
||||
->where('product_id', $product->id)
|
||||
->first();
|
||||
|
||||
$feedback = Feedback::firstOrCreate(
|
||||
[
|
||||
'customer_id' => $customer->id,
|
||||
'customer_product_id' => $customerProduct?->id,
|
||||
'contract_id' => $activeContract?->id,
|
||||
'title' => $title,
|
||||
],
|
||||
[
|
||||
'content' => $content,
|
||||
'status' => 'pending',
|
||||
'contract_id' => $activeContract?->id,
|
||||
'feedback_channel_id' => 1,
|
||||
]
|
||||
);
|
||||
@@ -429,9 +412,6 @@ class ImportCskh extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"}
|
||||
*/
|
||||
private function parseHandoverDate(mixed $value): ?string
|
||||
{
|
||||
if (empty($value)) {
|
||||
|
||||
39
app/Enums/ContractCategory.php
Normal file
39
app/Enums/ContractCategory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Contract Category Enum — Phân loại hợp đồng cấp cao
|
||||
*
|
||||
* Mỗi căn hộ chỉ có tối đa 1 HĐ active mỗi category tại cùng thời điểm.
|
||||
* - ownership: Sở hữu (đặt cọc, mua bán, chuyển nhượng...)
|
||||
* - business: Kinh doanh (cho thuê, tự doanh, hợp tác KD...)
|
||||
*/
|
||||
enum ContractCategory: string
|
||||
{
|
||||
case OWNERSHIP = 'ownership';
|
||||
case BUSINESS = 'business';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OWNERSHIP => __('enums.contract_category.ownership'),
|
||||
self::BUSINESS => __('enums.contract_category.business'),
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OWNERSHIP => 'success',
|
||||
self::BUSINESS => 'info',
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn ($case) => [
|
||||
$case->value => $case->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,33 @@
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Contract Type Enum
|
||||
* Contract Type Enum — Loại hợp đồng chi tiết (subtype của category)
|
||||
*
|
||||
* Để thêm loại hợp đồng mới:
|
||||
* 1. Thêm case mới vào enum
|
||||
* Ownership: deposit (đặt cọc), purchase (mua bán), transfer (chuyển nhượng)
|
||||
* Business: rental (cho thuê), self_business (tự doanh), bcc (hợp tác KD)
|
||||
*
|
||||
* Để thêm loại mới:
|
||||
* 1. Thêm case mới vào enum + gán category phù hợp trong category()
|
||||
* 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php
|
||||
* 3. Reload browser
|
||||
*/
|
||||
enum ContractType: string
|
||||
{
|
||||
case SALE = 'sale';
|
||||
case LEASE = 'lease';
|
||||
case DEPOSIT = 'deposit';
|
||||
case PURCHASE = 'purchase';
|
||||
case TRANSFER = 'transfer';
|
||||
case RENTAL = 'rental';
|
||||
case SELF_BUSINESS = 'self_business';
|
||||
case BCC = 'bcc';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SALE => __('enums.contract_type.sale'),
|
||||
self::LEASE => __('enums.contract_type.lease'),
|
||||
self::DEPOSIT => __('enums.contract_type.deposit'),
|
||||
self::PURCHASE => __('enums.contract_type.purchase'),
|
||||
self::TRANSFER => __('enums.contract_type.transfer'),
|
||||
self::RENTAL => __('enums.contract_type.rental'),
|
||||
self::SELF_BUSINESS => __('enums.contract_type.self_business'),
|
||||
self::BCC => __('enums.contract_type.bcc'),
|
||||
};
|
||||
}
|
||||
@@ -28,9 +37,20 @@ enum ContractType: string
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SALE => 'success',
|
||||
self::LEASE => 'info',
|
||||
self::BCC => 'warning',
|
||||
self::DEPOSIT => 'warning',
|
||||
self::PURCHASE => 'success',
|
||||
self::TRANSFER => 'info',
|
||||
self::RENTAL => 'info',
|
||||
self::SELF_BUSINESS => 'warning',
|
||||
self::BCC => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public function category(): ContractCategory
|
||||
{
|
||||
return match ($this) {
|
||||
self::DEPOSIT, self::PURCHASE, self::TRANSFER => ContractCategory::OWNERSHIP,
|
||||
self::RENTAL, self::SELF_BUSINESS, self::BCC => ContractCategory::BUSINESS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,4 +60,14 @@ enum ContractType: string
|
||||
$case->value => $case->label(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public static function ownershipCases(): array
|
||||
{
|
||||
return [self::DEPOSIT, self::PURCHASE, self::TRANSFER];
|
||||
}
|
||||
|
||||
public static function businessCases(): array
|
||||
{
|
||||
return [self::RENTAL, self::SELF_BUSINESS, self::BCC];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\Contracts;
|
||||
|
||||
use App\Enums\ContractCategory;
|
||||
use App\Filament\Resources\Contracts\Pages\CreateContract;
|
||||
use App\Filament\Resources\Contracts\Pages\EditContract;
|
||||
use App\Filament\Resources\Contracts\Pages\ListContracts;
|
||||
@@ -64,7 +65,6 @@ class ContractResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
// GlobalSearch customization
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getGlobalSearchEloquentQuery()
|
||||
@@ -89,6 +89,7 @@ class ContractResource extends Resource
|
||||
return [
|
||||
__('app.product') => $record->product?->name ?? '-',
|
||||
__('app.widget_customer') => $record->customer?->name ?? '-',
|
||||
__('enums.contract_category_label') => ContractCategory::from($record->category)->label(),
|
||||
__('app.blade_status') => \App\Enums\ContractStatus::from($record->status)->label(),
|
||||
__('app.signed_status') => $record->signed_status ?? '-',
|
||||
];
|
||||
|
||||
@@ -3,9 +3,38 @@
|
||||
namespace App\Filament\Resources\Contracts\Pages;
|
||||
|
||||
use App\Filament\Resources\Contracts\ContractResource;
|
||||
use App\Models\Contract;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateContract extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ContractResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (($data['status'] ?? '') === 'active') {
|
||||
$existing = Contract::where('product_id', $data['product_id'])
|
||||
->where('category', $data['category'])
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title(__('feedback.contract_duplicate_title', [
|
||||
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
|
||||
]))
|
||||
->body(__('feedback.contract_duplicate_body', [
|
||||
'product' => $existing->product?->name ?? '?',
|
||||
'customer' => $existing->customer?->name ?? '?',
|
||||
]))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
$this->halt();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Filament\Resources\Contracts\Pages;
|
||||
|
||||
use App\Filament\Resources\Contracts\ContractResource;
|
||||
use App\Models\Contract;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditContract extends EditRecord
|
||||
@@ -16,4 +18,32 @@ class EditContract extends EditRecord
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (($data['status'] ?? '') === 'active') {
|
||||
$existing = Contract::where('product_id', $data['product_id'])
|
||||
->where('category', $data['category'])
|
||||
->where('status', 'active')
|
||||
->where('id', '!=', $this->getRecord()->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title(__('feedback.contract_duplicate_title', [
|
||||
'category' => \App\Enums\ContractCategory::from($data['category'])->label(),
|
||||
]))
|
||||
->body(__('feedback.contract_duplicate_body', [
|
||||
'product' => $existing->product?->name ?? '?',
|
||||
'customer' => $existing->customer?->name ?? '?',
|
||||
]))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
$this->halt();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\Contracts\Schemas;
|
||||
|
||||
use App\Enums\ContractCategory;
|
||||
use App\Enums\ContractStatus;
|
||||
use App\Enums\ContractType;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
@@ -16,6 +17,36 @@ class ContractForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('app.contract_category_section'))
|
||||
->schema([
|
||||
Select::make('category')
|
||||
->label(__('enums.contract_category_label'))
|
||||
->options(ContractCategory::options())
|
||||
->required()
|
||||
->native(false)
|
||||
->live(),
|
||||
Select::make('type')
|
||||
->label(__('enums.contract_type_label'))
|
||||
->options(function ($get): array {
|
||||
$category = $get('category');
|
||||
if ($category === 'ownership') {
|
||||
return collect(ContractType::ownershipCases())->mapWithKeys(fn ($c) => [
|
||||
$c->value => $c->label(),
|
||||
])->toArray();
|
||||
}
|
||||
if ($category === 'business') {
|
||||
return collect(ContractType::businessCases())->mapWithKeys(fn ($c) => [
|
||||
$c->value => $c->label(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
return ContractType::options();
|
||||
})
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('app.contract_info'))
|
||||
->schema([
|
||||
Select::make('product_id')
|
||||
@@ -28,11 +59,6 @@ class ContractForm
|
||||
->relationship('customer', 'name')
|
||||
->searchable()
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->label(__('app.contract'))
|
||||
->options(ContractType::options())
|
||||
->required()
|
||||
->native(false),
|
||||
Select::make('status')
|
||||
->label(__('app.status'))
|
||||
->options(ContractStatus::ACTIVE->options())
|
||||
@@ -50,6 +76,22 @@ class ContractForm
|
||||
->maxLength(255),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('app.representative_info'))
|
||||
->description(__('app.representative_desc'))
|
||||
->schema([
|
||||
TextInput::make('representative_name')
|
||||
->label(__('app.name'))
|
||||
->maxLength(255),
|
||||
TextInput::make('representative_phone')
|
||||
->label(__('app.phone'))
|
||||
->maxLength(20),
|
||||
TextInput::make('representative_email')
|
||||
->label(__('app.email'))
|
||||
->email()
|
||||
->maxLength(255),
|
||||
])
|
||||
->columns(3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Filament\Resources\Contracts\Tables;
|
||||
|
||||
use App\Enums\ContractCategory;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ContractsTable
|
||||
@@ -17,15 +19,25 @@ class ContractsTable
|
||||
TextColumn::make('id')
|
||||
->sortable(),
|
||||
TextColumn::make('product.name')
|
||||
->label(__('app.product'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('customer.name')
|
||||
->label(__('app.widget_customer'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
TextColumn::make('category')
|
||||
->label(__('enums.contract_category_label'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()),
|
||||
->formatStateUsing(fn (string $state): string => ContractCategory::from($state)->label())
|
||||
->color(fn (string $state): string => ContractCategory::from($state)->color()),
|
||||
TextColumn::make('type')
|
||||
->label(__('enums.contract_type_label'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label())
|
||||
->color(fn (string $state): string => \App\Enums\ContractType::from($state)->color()),
|
||||
TextColumn::make('status')
|
||||
->label(__('app.status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
@@ -34,15 +46,22 @@ class ContractsTable
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()),
|
||||
TextColumn::make('representative_name')
|
||||
->label(__('app.representative'))
|
||||
->placeholder('-')
|
||||
->toggleable(),
|
||||
TextColumn::make('start_date')
|
||||
->label(__('app.start_date'))
|
||||
->date()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('end_date')
|
||||
->label(__('app.end_date'))
|
||||
->date()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('signed_status')
|
||||
->label(__('app.signed_status'))
|
||||
->toggleable(),
|
||||
TextColumn::make('feedbacks_count')
|
||||
->counts('feedbacks')
|
||||
@@ -53,7 +72,12 @@ class ContractsTable
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('category')
|
||||
->label(__('enums.contract_category_label'))
|
||||
->options(ContractCategory::options()),
|
||||
SelectFilter::make('status')
|
||||
->label(__('app.status'))
|
||||
->options(\App\Enums\ContractStatus::ACTIVE->options()),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
|
||||
@@ -23,12 +23,14 @@ class FeedbacksRelationManager extends RelationManager
|
||||
->recordTitleAttribute('title')
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->label(__('app.blade_title'))
|
||||
->searchable()
|
||||
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
|
||||
TextColumn::make('customerProduct.product.name')
|
||||
TextColumn::make('contract.product.name')
|
||||
->label(__('app.product'))
|
||||
->placeholder(__('app.general')),
|
||||
TextColumn::make('status')
|
||||
->label(__('app.status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
|
||||
@@ -41,15 +41,6 @@ class CustomerForm
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('app.owned_products'))
|
||||
->schema([
|
||||
Select::make('products')
|
||||
->label(__('app.resource_products'))
|
||||
->relationship('products', 'name')
|
||||
->multiple()
|
||||
->preload()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ class CustomersTable
|
||||
'inactive' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('products_count')
|
||||
->counts('products')
|
||||
->label(__('app.resource_products')),
|
||||
TextColumn::make('feedbacks_count')
|
||||
->counts('feedbacks')
|
||||
->label(__('app.feedbacks')),
|
||||
|
||||
@@ -65,11 +65,10 @@ class FeedbackResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
// GlobalSearch customization
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getGlobalSearchEloquentQuery()
|
||||
->with(['customer', 'customerProduct.product', 'currentDepartment']);
|
||||
->with(['customer', 'contract.product', 'currentDepartment']);
|
||||
}
|
||||
|
||||
public static function modifyGlobalSearchQuery(Builder $query, string $search): void
|
||||
@@ -83,7 +82,7 @@ class FeedbackResource extends Resource
|
||||
{
|
||||
return [
|
||||
__('app.widget_customer') => $record->customer?->name ?? '-',
|
||||
__('app.product') => $record->customerProduct?->product?->name ?? __('app.general'),
|
||||
__('app.product') => $record->contract?->product?->name ?? __('app.general'),
|
||||
__('app.blade_status') => match ($record->status) {
|
||||
'pending' => __('app.status_pending'),
|
||||
'processing' => __('app.status_processing'),
|
||||
|
||||
@@ -15,14 +15,10 @@ class CreateFeedback extends CreateRecord
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (($data['is_general'] ?? false) === true) {
|
||||
$data['customer_product_id'] = null;
|
||||
}
|
||||
|
||||
$this->pendingAttachments = $data['attachments'] ?? [];
|
||||
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
||||
|
||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
|
||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class EditFeedback extends EditRecord
|
||||
->disk('public')
|
||||
->directory('feedback-attachments')
|
||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
||||
->maxSize(51200) // 50MB in KB
|
||||
->maxSize(51200)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
@@ -166,14 +166,10 @@ class EditFeedback extends EditRecord
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (($data['is_general'] ?? false) === true) {
|
||||
$data['customer_product_id'] = null;
|
||||
}
|
||||
|
||||
$this->pendingAttachments = $data['attachments'] ?? [];
|
||||
$this->pendingCollection = $data['attachment_collection'] ?? 'general';
|
||||
|
||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments']);
|
||||
unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']);
|
||||
|
||||
$newStatus = $data['status'] ?? null;
|
||||
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
|
||||
@@ -235,7 +231,7 @@ class EditFeedback extends EditRecord
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$data['is_general'] = $data['customer_product_id'] === null;
|
||||
$data['is_general'] = $data['contract_id'] === null;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Schemas;
|
||||
|
||||
use App\Models\CustomerProduct;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Department;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
@@ -25,6 +25,7 @@ class FeedbackForm
|
||||
->icon('heroicon-o-user-group')
|
||||
->schema([
|
||||
Select::make('customer_id')
|
||||
->label(__('app.widget_customer'))
|
||||
->relationship('customer', 'name')
|
||||
->searchable()
|
||||
->required()
|
||||
@@ -37,48 +38,51 @@ class FeedbackForm
|
||||
->live()
|
||||
->columnSpan(1),
|
||||
|
||||
Select::make('customer_product_id')
|
||||
Select::make('product_id_virtual')
|
||||
->label(__('app.product'))
|
||||
->searchable()
|
||||
->options(fn ($get): array => self::getProductOptionsForCustomer($get('customer_id')))
|
||||
->getSearchResultsUsing(function (string $search, $get): array {
|
||||
$customerId = $get('customer_id');
|
||||
if (! $customerId) {
|
||||
return [];
|
||||
}
|
||||
$keyword = mb_strtolower($search);
|
||||
return CustomerProduct::where('customer_id', $customerId)
|
||||
->whereHas('product', fn ($q) => $q->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"]))
|
||||
->with('product')
|
||||
$allIds = self::getProductIdsForCustomer($customerId);
|
||||
|
||||
if ($allIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\Product::whereIn('id', $allIds)
|
||||
->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"])
|
||||
->limit(50)
|
||||
->get()
|
||||
->pluck('product.name', 'id')
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
})
|
||||
->getOptionLabelUsing(fn ($value): ?string =>
|
||||
CustomerProduct::find($value)?->product?->name
|
||||
\App\Models\Product::find($value)?->name
|
||||
)
|
||||
->visible(fn ($get): bool => ! $get('is_general'))
|
||||
->nullable()
|
||||
->live(),
|
||||
->live()
|
||||
->columnSpan(1),
|
||||
|
||||
Select::make('contract_id')
|
||||
->label(__('app.contract'))
|
||||
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
|
||||
->visible(fn ($get): bool => ! $get('is_general') && filled($get('product_id_virtual')))
|
||||
->options(function ($get): array {
|
||||
$customerProductId = $get('customer_product_id');
|
||||
if (! $customerProductId) {
|
||||
$productId = $get('product_id_virtual');
|
||||
if (! $productId) {
|
||||
return [];
|
||||
}
|
||||
$cp = \App\Models\CustomerProduct::find($customerProductId);
|
||||
if (! $cp) {
|
||||
return [];
|
||||
}
|
||||
return \App\Models\Contract::where('product_id', $cp->product_id)
|
||||
|
||||
return Contract::where('product_id', $productId)
|
||||
->where('status', 'active')
|
||||
->with('customer')
|
||||
->get()
|
||||
->mapWithKeys(fn ($c) => [
|
||||
$c->id => sprintf('%s (%s) - %s',
|
||||
$c->id => sprintf('%s — %s (%s)',
|
||||
\App\Enums\ContractType::from($c->type)->label(),
|
||||
$c->customer->name,
|
||||
$c->start_date?->format('d/m/Y') ?? 'N/A',
|
||||
@@ -90,15 +94,11 @@ class FeedbackForm
|
||||
if (filled($state)) {
|
||||
return;
|
||||
}
|
||||
$customerProductId = $get('customer_product_id');
|
||||
if (! $customerProductId) {
|
||||
$productId = $get('product_id_virtual');
|
||||
if (! $productId) {
|
||||
return;
|
||||
}
|
||||
$cp = \App\Models\CustomerProduct::find($customerProductId);
|
||||
if (! $cp) {
|
||||
return;
|
||||
}
|
||||
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
|
||||
$activeContract = Contract::where('product_id', $productId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
if ($activeContract) {
|
||||
@@ -115,17 +115,20 @@ class FeedbackForm
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->schema([
|
||||
Select::make('feedback_channel_id')
|
||||
->label(__('app.channel'))
|
||||
->relationship('feedbackChannel', 'name')
|
||||
->required()
|
||||
->preload()
|
||||
->columnSpan(1),
|
||||
|
||||
TextInput::make('title')
|
||||
->label(__('app.blade_title'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(1),
|
||||
|
||||
Textarea::make('content')
|
||||
->label(__('app.description'))
|
||||
->required()
|
||||
->rows(6)
|
||||
->columnSpanFull(),
|
||||
@@ -136,9 +139,9 @@ class FeedbackForm
|
||||
->preload()
|
||||
->columnSpanFull()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')->required(),
|
||||
TextInput::make('slug')->required(),
|
||||
ColorPicker::make('color')->default('#3b82f6'),
|
||||
TextInput::make('name')->label(__('app.name'))->required(),
|
||||
TextInput::make('slug')->label(__('app.slug'))->required(),
|
||||
ColorPicker::make('color')->label(__('app.color'))->default('#3b82f6'),
|
||||
]),
|
||||
])
|
||||
->columns(2),
|
||||
@@ -148,6 +151,7 @@ class FeedbackForm
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->schema([
|
||||
Select::make('status')
|
||||
->label(__('app.status'))
|
||||
->options([
|
||||
'pending' => __('app.status_pending'),
|
||||
'processing' => __('app.status_processing'),
|
||||
@@ -159,6 +163,7 @@ class FeedbackForm
|
||||
->columnSpan(1),
|
||||
|
||||
Select::make('assigned_to')
|
||||
->label(__('app.assigned_to'))
|
||||
->relationship('assignedTo', 'name')
|
||||
->searchable()
|
||||
->nullable()
|
||||
@@ -198,10 +203,42 @@ class FeedbackForm
|
||||
->disk('public')
|
||||
->directory('feedback-attachments')
|
||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime'])
|
||||
->maxSize(51200) // 50MB in KB
|
||||
->maxSize(51200)
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getProductOptionsForCustomer(?int $customerId): array
|
||||
{
|
||||
$ids = self::getProductIdsForCustomer($customerId);
|
||||
|
||||
return \App\Models\Product::whereIn('id', $ids)
|
||||
->limit(100)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function getProductIdsForCustomer(?int $customerId): \Illuminate\Support\Collection
|
||||
{
|
||||
if (! $customerId) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$ownedIds = Contract::where('customer_id', $customerId)
|
||||
->where('category', 'ownership')
|
||||
->where('status', 'active')
|
||||
->pluck('product_id');
|
||||
|
||||
$customer = \App\Models\Customer::find($customerId);
|
||||
$repIds = collect();
|
||||
if ($customer?->email) {
|
||||
$repIds = Contract::where('representative_email', $customer->email)
|
||||
->where('status', 'active')
|
||||
->pluck('product_id');
|
||||
}
|
||||
|
||||
return $ownedIds->merge($repIds)->unique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,18 @@ class FeedbackTable
|
||||
->color('primary'),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('app.blade_title'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('semibold')
|
||||
->limit(40),
|
||||
|
||||
TextColumn::make('customer.name')
|
||||
->label(__('app.widget_customer'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('customerProduct.product.name')
|
||||
TextColumn::make('contract.product.name')
|
||||
->label(__('app.product'))
|
||||
->placeholder(__('app.general'))
|
||||
->sortable()
|
||||
@@ -43,9 +45,12 @@ class FeedbackTable
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'sale' => 'success',
|
||||
'lease' => 'info',
|
||||
'bcc' => 'warning',
|
||||
'deposit' => 'warning',
|
||||
'purchase' => 'success',
|
||||
'transfer' => 'info',
|
||||
'rental' => 'info',
|
||||
'self_business' => 'warning',
|
||||
'bcc' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->placeholder('-')
|
||||
@@ -62,6 +67,7 @@ class FeedbackTable
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('app.status'))
|
||||
->badge()
|
||||
->color(fn(string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
|
||||
@@ -29,9 +29,9 @@ class ProductsTable
|
||||
TextColumn::make('description')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('customers_count')
|
||||
->counts('customers')
|
||||
->label(__('app.owners')),
|
||||
TextColumn::make('contracts_count')
|
||||
->counts('contracts')
|
||||
->label(__('app.contracts')),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
|
||||
@@ -45,7 +45,7 @@ class ResolvedTicketsWidget extends BaseWidget
|
||||
->label(__('app.widget_customer'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('customerProduct.product.name')
|
||||
TextColumn::make('contract.product.name')
|
||||
->label(__('app.product'))
|
||||
->placeholder(__('app.general')),
|
||||
|
||||
|
||||
@@ -7,9 +7,30 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['product_id', 'customer_id', 'type', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status'])]
|
||||
#[Fillable(['product_id', 'customer_id', 'type', 'category', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status', 'representative_name', 'representative_phone', 'representative_email'])]
|
||||
class Contract extends Model
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $contract) {
|
||||
if ($contract->status === 'active') {
|
||||
$exists = self::where('product_id', $contract->product_id)
|
||||
->where('category', $contract->category)
|
||||
->where('status', 'active')
|
||||
->when($contract->exists, fn ($q) => $q->where('id', '!=', $contract->id))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new \RuntimeException(
|
||||
__('feedback.contract_duplicate_body', [
|
||||
'product' => $contract->product?->name ?? $contract->product_id,
|
||||
'customer' => $contract->customer?->name ?? '?',
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -6,14 +6,19 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'email', 'phone', 'address', 'status'])]
|
||||
class Customer extends Model
|
||||
{
|
||||
public function products(): BelongsToMany
|
||||
/**
|
||||
* Các căn hộ khách hàng sở hữu (qua HĐ Ownership active)
|
||||
*/
|
||||
public function ownedProducts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'customer_product')
|
||||
->withPivot('purchase_date')
|
||||
return $this->belongsToMany(Product::class, 'contracts')
|
||||
->wherePivot('category', 'ownership')
|
||||
->wherePivot('status', 'active')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CustomerProduct extends Model
|
||||
{
|
||||
protected $table = 'customer_product';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
'product_id',
|
||||
'purchase_date',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'purchase_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
|
||||
#[Fillable(['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
|
||||
class Feedback extends Model
|
||||
{
|
||||
public function customer(): BelongsTo
|
||||
@@ -16,11 +16,6 @@ class Feedback extends Model
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function customerProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contract::class);
|
||||
|
||||
@@ -4,19 +4,11 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'description', 'status'])]
|
||||
class Product extends Model
|
||||
{
|
||||
public function customers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Customer::class, 'customer_product')
|
||||
->withPivot('purchase_date')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function contracts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Contract::class);
|
||||
|
||||
Reference in New Issue
Block a user