Version 2.0

This commit is contained in:
2026-05-12 09:23:05 +00:00
parent f2bc048219
commit f1b68e5e78
34 changed files with 564 additions and 283 deletions

View File

@@ -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)) {

View 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ỉ tối đa 1 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();
}
}

View File

@@ -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 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];
}
}

View File

@@ -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 ?? '-',
];

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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(),
]),
]);
}
}

View File

@@ -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')),

View File

@@ -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'),

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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',

View File

@@ -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()

View File

@@ -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')),

View File

@@ -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 [

View File

@@ -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 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();
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);