Changes to documents able to edit them now, also support for auto mail attechemnts
This commit is contained in:
parent
761799bdbe
commit
3b1a24287a
|
|
@ -252,14 +252,19 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||||
'contract_uuid' => 'nullable|uuid',
|
'contract_uuid' => 'nullable|uuid',
|
||||||
'send_auto_mail' => 'sometimes|boolean',
|
'send_auto_mail' => 'sometimes|boolean',
|
||||||
|
'attachment_document_ids' => 'sometimes|array',
|
||||||
|
'attachment_document_ids.*' => 'integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Map contract_uuid to contract_id within the same client case, if provided
|
// Map contract_uuid to contract_id within the same client case, if provided
|
||||||
$contractId = null;
|
$contractId = null;
|
||||||
if (! empty($attributes['contract_uuid'])) {
|
if (! empty($attributes['contract_uuid'])) {
|
||||||
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail(['id']);
|
$contract = Contract::withTrashed()
|
||||||
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
|
->where('client_case_id', $clientCase->id)
|
||||||
|
->first();
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
// Archived contracts are now allowed: link activity regardless of active flag
|
// Archived contracts are allowed: link activity regardless of active flag
|
||||||
$contractId = $contract->id;
|
$contractId = $contract->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +289,22 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
try {
|
try {
|
||||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag);
|
// Filter attachments to those belonging to the selected contract
|
||||||
|
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||||
|
->filter()
|
||||||
|
->map(fn ($v) => (int) $v)
|
||||||
|
->values();
|
||||||
|
$validAttachmentIds = collect();
|
||||||
|
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||||
|
$validAttachmentIds = \App\Models\Document::query()
|
||||||
|
->where('documentable_type', \App\Models\Contract::class)
|
||||||
|
->where('documentable_id', $contractId)
|
||||||
|
->whereIn('id', $attachmentIds)
|
||||||
|
->pluck('id');
|
||||||
|
}
|
||||||
|
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
|
||||||
|
'attachment_ids' => $validAttachmentIds->all(),
|
||||||
|
]);
|
||||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||||
// If template requires contract and user attempted to send, surface a validation message
|
// If template requires contract and user attempted to send, surface a validation message
|
||||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||||||
|
|
@ -331,10 +351,6 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re
|
||||||
{
|
{
|
||||||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||||||
|
|
||||||
\DB::transaction(function () use ($contract) {
|
|
||||||
$contract->delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Preserve segment filter if present
|
// Preserve segment filter if present
|
||||||
$segment = request('segment');
|
$segment = request('segment');
|
||||||
|
|
||||||
|
|
@ -484,6 +500,89 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
||||||
return back()->with('success', 'Document uploaded.');
|
return back()->with('success', 'Document uploaded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||||
|
{
|
||||||
|
// Validate that the document being updated is scoped to this case (or one of its contracts).
|
||||||
|
// Ensure the document belongs to this case or its contracts
|
||||||
|
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
|
||||||
|
$belongsToContractOfCase = false;
|
||||||
|
if ($document->documentable_type === Contract::class) {
|
||||||
|
$belongsToContractOfCase = Contract::withTrashed()
|
||||||
|
->where('id', $document->documentable_id)
|
||||||
|
->where('client_case_id', $clientCase->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($belongsToCase || $belongsToContractOfCase)) {
|
||||||
|
logger()->warning('Document update 404: document not in scope of client case or its contracts', [
|
||||||
|
'doc_id' => $document->id,
|
||||||
|
'doc_uuid' => $document->uuid,
|
||||||
|
'doc_type' => $document->documentable_type,
|
||||||
|
'doc_doc_id' => $document->documentable_id,
|
||||||
|
'route_case_id' => $clientCase->id,
|
||||||
|
'route_case_uuid' => $clientCase->uuid,
|
||||||
|
]);
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strictly validate that provided contract_uuid (when present) belongs to THIS client case.
|
||||||
|
// If a different case's contract UUID is provided, return a validation error (422) instead of falling back.
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_public' => 'sometimes|boolean',
|
||||||
|
// Optional reassignment to a contract within the same case
|
||||||
|
// Note: empty string explicitly means "move back to case" when the key exists in the request.
|
||||||
|
'contract_uuid' => [
|
||||||
|
'nullable',
|
||||||
|
'uuid',
|
||||||
|
\Illuminate\Validation\Rule::exists('contracts', 'uuid')->where(function ($q) use ($clientCase, $request) {
|
||||||
|
// Allow empty string if key exists (handled later) by skipping exists check when empty
|
||||||
|
$incoming = $request->input('contract_uuid');
|
||||||
|
if (is_null($incoming) || $incoming === '') {
|
||||||
|
// Return a condition that always matches something harmless; exists rule is ignored in this case
|
||||||
|
return $q; // no-op, DBAL will still run but empty will be caught by nullable
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->where('client_case_id', $clientCase->id);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Basic attribute updates
|
||||||
|
$document->name = $validated['name'] ?? $document->name;
|
||||||
|
if (array_key_exists('description', $validated)) {
|
||||||
|
$document->description = $validated['description'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('is_public', $validated)) {
|
||||||
|
$document->is_public = (bool) $validated['is_public'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign to contract or back to case IF the key is present in the payload (explicit intent).
|
||||||
|
if ($request->exists('contract_uuid')) {
|
||||||
|
$incoming = $request->input('contract_uuid');
|
||||||
|
if ($incoming === '' || is_null($incoming)) {
|
||||||
|
// Explicitly move relation back to the case
|
||||||
|
$document->documentable_type = ClientCase::class;
|
||||||
|
$document->documentable_id = $clientCase->id;
|
||||||
|
} else {
|
||||||
|
// Safe to resolve within this case due to the validation rule above
|
||||||
|
$target = $clientCase->contracts()->where('uuid', $incoming)->firstOrFail(['id', 'uuid', 'active']);
|
||||||
|
if (! $target->active) {
|
||||||
|
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->documentable_type = Contract::class;
|
||||||
|
$document->documentable_id = $target->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->save();
|
||||||
|
|
||||||
|
// Refresh documents list on page
|
||||||
|
return back()->with('success', __('Document updated.'));
|
||||||
|
}
|
||||||
|
|
||||||
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
|
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||||
{
|
{
|
||||||
// Ensure the document belongs to this client case or its contracts
|
// Ensure the document belongs to this client case or its contracts
|
||||||
|
|
@ -1154,7 +1253,7 @@ public function show(ClientCase $clientCase)
|
||||||
$contractDocs = collect();
|
$contractDocs = collect();
|
||||||
} else {
|
} else {
|
||||||
$contractDocs = Document::query()
|
$contractDocs = Document::query()
|
||||||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at'])
|
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
|
||||||
->where('documentable_type', Contract::class)
|
->where('documentable_type', Contract::class)
|
||||||
->whereIn('documentable_id', $contractIds)
|
->whereIn('documentable_id', $contractIds)
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
|
|
@ -1170,7 +1269,7 @@ public function show(ClientCase $clientCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
$caseDocs = $case->documents()
|
$caseDocs = $case->documents()
|
||||||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at'])
|
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->limit(200)
|
->limit(200)
|
||||||
->get()
|
->get()
|
||||||
|
|
@ -1243,7 +1342,7 @@ function ($p) {
|
||||||
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
||||||
},
|
},
|
||||||
'decisions.emailTemplate' => function ($q) {
|
'decisions.emailTemplate' => function ($q) {
|
||||||
$q->select('id', 'name', 'entity_types');
|
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,21 @@
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\DocumentTemplate;
|
use App\Models\DocumentTemplate;
|
||||||
use App\Services\Documents\TokenValueResolver;
|
use App\Services\Documents\TokenValueResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
|
|
||||||
class ContractDocumentGenerationController extends Controller
|
class ContractDocumentGenerationController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, Contract $contract): Response|RedirectResponse
|
public function __invoke(Request $request, Contract $contract): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
|
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
|
||||||
$isInertia = (bool) $request->header('X-Inertia');
|
$isInertia = (bool) $request->header('X-Inertia');
|
||||||
$wantsJson = ! $isInertia && ($request->expectsJson() || $request->wantsJson());
|
// For non-Inertia POSTs, prefer JSON responses by default (including tests)
|
||||||
|
$wantsJson = ! $isInertia;
|
||||||
if (Gate::denies('read')) { // baseline read permission required to generate
|
if (Gate::denies('read')) { // baseline read permission required to generate
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,17 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'account_types' => \App\Models\AccountType::all(),
|
'account_types' => \App\Models\AccountType::all(),
|
||||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
// Provide decisions with linked email template metadata (entity_types, allow_attachments)
|
||||||
|
'actions' => \App\Models\Action::query()
|
||||||
|
->with([
|
||||||
|
'decisions' => function ($q) {
|
||||||
|
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
||||||
|
},
|
||||||
|
'decisions.emailTemplate' => function ($q) {
|
||||||
|
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
'activities' => $activities,
|
'activities' => $activities,
|
||||||
'completed_mode' => (bool) $request->boolean('completed'),
|
'completed_mode' => (bool) $request->boolean('completed'),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public function rules(): array
|
||||||
'text_template' => ['nullable', 'string'],
|
'text_template' => ['nullable', 'string'],
|
||||||
'entity_types' => ['nullable', 'array'],
|
'entity_types' => ['nullable', 'array'],
|
||||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
'active' => ['boolean'],
|
'active' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public function rules(): array
|
||||||
'text_template' => ['nullable', 'string'],
|
'text_template' => ['nullable', 'string'],
|
||||||
'entity_types' => ['nullable', 'array'],
|
'entity_types' => ['nullable', 'array'],
|
||||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
'active' => ['boolean'],
|
'active' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ class EmailTemplate extends Model
|
||||||
'html_template',
|
'html_template',
|
||||||
'text_template',
|
'text_template',
|
||||||
'entity_types',
|
'entity_types',
|
||||||
|
'allow_attachments',
|
||||||
'active',
|
'active',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
'entity_types' => 'array',
|
'entity_types' => 'array',
|
||||||
|
'allow_attachments' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function documents(): MorphMany
|
public function documents(): MorphMany
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public function __construct(public EmailTemplateRenderer $renderer) {}
|
||||||
* Attempt to queue an auto mail for the given activity based on its decision/template.
|
* Attempt to queue an auto mail for the given activity based on its decision/template.
|
||||||
* Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason'].
|
* Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason'].
|
||||||
*/
|
*/
|
||||||
public function maybeQueue(Activity $activity, bool $sendFlag = true): array
|
public function maybeQueue(Activity $activity, bool $sendFlag = true, array $options = []): array
|
||||||
{
|
{
|
||||||
$decision = $activity->decision;
|
$decision = $activity->decision;
|
||||||
if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) {
|
if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) {
|
||||||
|
|
@ -114,6 +114,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array
|
||||||
if (count($recipients) > 1) {
|
if (count($recipients) > 1) {
|
||||||
$log->to_email = null;
|
$log->to_email = null;
|
||||||
}
|
}
|
||||||
|
// Resolve and attach selected documents as attachments metadata (id, name, path, mime, size)
|
||||||
|
$attachmentIds = collect($options['attachment_ids'] ?? [])->filter()->map(fn ($v) => (int) $v)->values();
|
||||||
|
if ($attachmentIds->isNotEmpty()) {
|
||||||
|
$docs = \App\Models\Document::query()
|
||||||
|
->whereIn('id', $attachmentIds)
|
||||||
|
->get(['id', 'disk', 'path', 'original_name', 'name', 'mime_type', 'size']);
|
||||||
|
$log->attachments = $docs->map(function ($d) {
|
||||||
|
return [
|
||||||
|
'id' => $d->id,
|
||||||
|
'disk' => $d->disk ?: 'public',
|
||||||
|
'path' => $d->path,
|
||||||
|
'name' => $d->original_name ?: ($d->name ?: basename($d->path)),
|
||||||
|
'mime' => $d->mime_type ?: 'application/octet-stream',
|
||||||
|
'size' => $d->size,
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
$log->save();
|
$log->save();
|
||||||
|
|
||||||
$log->body()->create([
|
$log->body()->create([
|
||||||
|
|
|
||||||
|
|
@ -149,14 +149,14 @@ public function sendFromLog(EmailLog $log): array
|
||||||
if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) {
|
if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) {
|
||||||
throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id);
|
throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id);
|
||||||
}
|
}
|
||||||
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
|
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always BCC the sender mailbox if present and not already in To
|
// Always BCC the sender mailbox if present and not already in To
|
||||||
$senderBcc = null;
|
$senderBcc = null;
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||||
// Check duplicates against toList
|
// Check duplicates against toList
|
||||||
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||||
$senderBcc = $fromAddr;
|
$senderBcc = $fromAddr;
|
||||||
$email->bcc(new Address($senderBcc));
|
$email->bcc(new Address($senderBcc));
|
||||||
|
|
@ -175,6 +175,26 @@ public function sendFromLog(EmailLog $log): array
|
||||||
$email->replyTo($log->reply_to);
|
$email->replyTo($log->reply_to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach files if present on the log
|
||||||
|
$attachments = (array) ($log->attachments ?? []);
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
try {
|
||||||
|
$disk = $att['disk'] ?? 'public';
|
||||||
|
$path = $att['path'] ?? null;
|
||||||
|
if (! $path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = $att['name'] ?? basename($path);
|
||||||
|
$mime = $att['mime'] ?? 'application/octet-stream';
|
||||||
|
$full = \Storage::disk($disk)->path($path);
|
||||||
|
if (is_file($full)) {
|
||||||
|
$email->attachFromPath($full, $name, $mime);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore individual attachment failures; continue sending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$mailer->send($email);
|
$mailer->send($email);
|
||||||
// Save log if we modified BCC
|
// Save log if we modified BCC
|
||||||
if (! empty($log->getAttribute('bcc'))) {
|
if (! empty($log->getAttribute('bcc'))) {
|
||||||
|
|
@ -205,7 +225,7 @@ public function sendFromLog(EmailLog $log): array
|
||||||
// BCC the sender mailbox if resolvable and not already in To
|
// BCC the sender mailbox if resolvable and not already in To
|
||||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||||
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||||
$message->bcc($fromAddr);
|
$message->bcc($fromAddr);
|
||||||
$log->bcc = [$fromAddr];
|
$log->bcc = [$fromAddr];
|
||||||
|
|
@ -240,7 +260,7 @@ public function sendFromLog(EmailLog $log): array
|
||||||
// BCC the sender mailbox if resolvable and not already in To
|
// BCC the sender mailbox if resolvable and not already in To
|
||||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
||||||
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
||||||
$message->bcc($fromAddr);
|
$message->bcc($fromAddr);
|
||||||
$log->bcc = [$fromAddr];
|
$log->bcc = [$fromAddr];
|
||||||
|
|
|
||||||
45
database/factories/DocumentFactory.php
Normal file
45
database/factories/DocumentFactory.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Document>
|
||||||
|
*/
|
||||||
|
class DocumentFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$ext = $this->faker->randomElement(['pdf', 'txt', 'png', 'jpeg']);
|
||||||
|
$nameBase = $this->faker->words(3, true);
|
||||||
|
$fileName = $nameBase.'.'.$ext;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $nameBase,
|
||||||
|
'description' => $this->faker->optional()->sentence(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => 'cases/'.$this->faker->uuid().'/documents/'.$fileName,
|
||||||
|
'file_name' => $fileName,
|
||||||
|
'original_name' => $fileName,
|
||||||
|
'extension' => $ext,
|
||||||
|
'mime_type' => match ($ext) {
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'txt' => 'text/plain',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
default => 'application/octet-stream',
|
||||||
|
},
|
||||||
|
'size' => $this->faker->numberBetween(1000, 1000000),
|
||||||
|
'checksum' => null,
|
||||||
|
'is_public' => $this->faker->boolean(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('email_templates', 'allow_attachments')) {
|
||||||
|
$table->boolean('allow_attachments')->default(false)->after('entity_types');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('email_templates', 'allow_attachments')) {
|
||||||
|
$table->dropColumn('allow_attachments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
96
resources/js/Components/DocumentEditDialog.vue
Normal file
96
resources/js/Components/DocumentEditDialog.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup>
|
||||||
|
import DialogModal from '@/Components/DialogModal.vue'
|
||||||
|
import InputLabel from '@/Components/InputLabel.vue'
|
||||||
|
import TextInput from '@/Components/TextInput.vue'
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
client_case_uuid: { type: String, required: true },
|
||||||
|
document: { type: Object, default: null },
|
||||||
|
contracts: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_public: false,
|
||||||
|
contract_uuid: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.document,
|
||||||
|
(d) => {
|
||||||
|
if (!d) return
|
||||||
|
form.name = d.name || d.original_name || ''
|
||||||
|
form.description = d.description || ''
|
||||||
|
form.is_public = !!d.is_public
|
||||||
|
// Pre-fill contract selection if this doc belongs to a contract
|
||||||
|
const isContract = (d?.documentable_type || '').toLowerCase().includes('contract')
|
||||||
|
form.contract_uuid = isContract ? d.contract_uuid || null : null
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!props.document) return
|
||||||
|
form.patch(
|
||||||
|
route('clientCase.document.update', {
|
||||||
|
client_case: props.client_case_uuid,
|
||||||
|
document: props.document.uuid,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
emit('saved')
|
||||||
|
emit('close')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractOptions = computed(() => {
|
||||||
|
return props.contracts || []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal :show="show" @close="$emit('close')">
|
||||||
|
<template #title>Uredi dokument</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<InputLabel for="docName" value="Ime" />
|
||||||
|
<TextInput id="docName" v-model="form.name" class="mt-1 block w-full" />
|
||||||
|
<div v-if="form.errors.name" class="text-sm text-red-600 mt-1">{{ form.errors.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel for="docDesc" value="Opis" />
|
||||||
|
<TextInput id="docDesc" v-model="form.description" class="mt-1 block w-full" />
|
||||||
|
<div v-if="form.errors.description" class="text-sm text-red-600 mt-1">{{ form.errors.description }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="docPublic" type="checkbox" v-model="form.is_public" />
|
||||||
|
<InputLabel for="docPublic" value="Javno" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel for="docContract" value="Pogodba" />
|
||||||
|
<select id="docContract" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
<option :value="null">— Brez — (dok. pri primeru)</option>
|
||||||
|
<option v-for="c in contractOptions" :key="c.uuid || c.id" :value="c.uuid">{{ c.reference || c.uuid }}</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="form.errors.contract_uuid" class="text-sm text-red-600 mt-1">{{ form.errors.contract_uuid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="$emit('close')">Prekliči</button>
|
||||||
|
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
@ -45,7 +45,7 @@ const sourceLabel = (doc) => {
|
||||||
return "Primer";
|
return "Primer";
|
||||||
};
|
};
|
||||||
|
|
||||||
const emit = defineEmits(["view", "download", "delete"]);
|
const emit = defineEmits(["view", "download", "delete", "edit"]);
|
||||||
|
|
||||||
const formatSize = (bytes) => {
|
const formatSize = (bytes) => {
|
||||||
if (bytes == null) return "-";
|
if (bytes == null) return "-";
|
||||||
|
|
@ -318,6 +318,14 @@ function closeActions() {
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||||
|
@click="emit('edit', doc)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||||
|
<span>Uredi</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const form = useForm({
|
||||||
html_template: props.template?.html_template ?? "",
|
html_template: props.template?.html_template ?? "",
|
||||||
text_template: props.template?.text_template ?? "",
|
text_template: props.template?.text_template ?? "",
|
||||||
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
||||||
|
allow_attachments: props.template?.allow_attachments ?? false,
|
||||||
active: props.template?.active ?? true,
|
active: props.template?.active ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1065,6 +1066,10 @@ watch(
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="allow_attachments" type="checkbox" v-model="form.allow_attachments" />
|
||||||
|
<label for="allow_attachments" class="text-sm text-gray-700">Dovoli priponke</label>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input id="active" type="checkbox" v-model="form.active" />
|
<input id="active" type="checkbox" v-model="form.active" />
|
||||||
<label for="active" class="text-sm text-gray-700">Aktivno</label>
|
<label for="active" class="text-sm text-gray-700">Aktivno</label>
|
||||||
|
|
|
||||||
|
|
@ -4,64 +4,85 @@ import BasicButton from "@/Components/buttons/BasicButton.vue";
|
||||||
import DialogModal from "@/Components/DialogModal.vue";
|
import DialogModal from "@/Components/DialogModal.vue";
|
||||||
import InputLabel from "@/Components/InputLabel.vue";
|
import InputLabel from "@/Components/InputLabel.vue";
|
||||||
import DatePickerField from "@/Components/DatePickerField.vue";
|
import DatePickerField from "@/Components/DatePickerField.vue";
|
||||||
import TextInput from "@/Components/TextInput.vue";
|
|
||||||
import CurrencyInput from "@/Components/CurrencyInput.vue";
|
import CurrencyInput from "@/Components/CurrencyInput.vue";
|
||||||
import { useForm } from "@inertiajs/vue3";
|
import { useForm, usePage } from "@inertiajs/vue3";
|
||||||
import { FwbTextarea } from "flowbite-vue";
|
import { FwbTextarea } from "flowbite-vue";
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
client_case: { type: Object, required: true },
|
||||||
default: false,
|
actions: { type: Array, default: () => [] },
|
||||||
},
|
|
||||||
client_case: Object,
|
|
||||||
actions: Array,
|
|
||||||
// optionally pre-select a contract to attach the activity to
|
|
||||||
contractUuid: { type: String, default: null },
|
contractUuid: { type: String, default: null },
|
||||||
|
phoneMode: { type: Boolean, default: false },
|
||||||
|
// Prefer passing these from parent (e.g., phone view) to avoid reliance on global page props in teleports
|
||||||
|
documents: { type: Array, default: null },
|
||||||
|
contracts: { type: Array, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const decisions = ref(props.actions[0].decisions);
|
const page = usePage();
|
||||||
|
|
||||||
console.log(props.actions);
|
const decisions = ref(
|
||||||
|
Array.isArray(props.actions) && props.actions.length > 0
|
||||||
|
? props.actions[0].decisions || []
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits(["close"]);
|
const emit = defineEmits(["close"]);
|
||||||
|
const close = () => emit("close");
|
||||||
const close = () => {
|
|
||||||
emit("close");
|
|
||||||
};
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
due_date: null,
|
due_date: null,
|
||||||
amount: null,
|
amount: null,
|
||||||
note: "",
|
note: "",
|
||||||
action_id: props.actions[0].id,
|
action_id:
|
||||||
decision_id: props.actions[0].decisions[0].id,
|
Array.isArray(props.actions) && props.actions.length > 0 ? props.actions[0].id : null,
|
||||||
|
decision_id:
|
||||||
|
Array.isArray(props.actions) &&
|
||||||
|
props.actions.length > 0 &&
|
||||||
|
Array.isArray(props.actions[0].decisions) &&
|
||||||
|
props.actions[0].decisions.length > 0
|
||||||
|
? props.actions[0].decisions[0].id
|
||||||
|
: null,
|
||||||
contract_uuid: props.contractUuid,
|
contract_uuid: props.contractUuid,
|
||||||
send_auto_mail: true,
|
send_auto_mail: true,
|
||||||
|
attach_documents: false,
|
||||||
|
attachment_document_ids: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.actions,
|
||||||
|
(list) => {
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
decisions.value = [];
|
||||||
|
form.action_id = null;
|
||||||
|
form.decision_id = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.action_id) {
|
||||||
|
form.action_id = list[0].id;
|
||||||
|
}
|
||||||
|
const found = list.find((el) => el.id === form.action_id) || list[0];
|
||||||
|
decisions.value = Array.isArray(found.decisions) ? found.decisions : [];
|
||||||
|
if (!form.decision_id && decisions.value.length > 0) {
|
||||||
|
form.decision_id = decisions.value[0].id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => form.action_id,
|
() => form.action_id,
|
||||||
(action_id) => {
|
(action_id) => {
|
||||||
decisions.value = props.actions.filter((el) => el.id === action_id)[0].decisions;
|
const a = Array.isArray(props.actions)
|
||||||
form.decision_id = decisions.value[0].id;
|
? props.actions.find((el) => el.id === action_id)
|
||||||
// reset send flag on action change (will re-evaluate below)
|
: null;
|
||||||
|
decisions.value = Array.isArray(a?.decisions) ? a.decisions : [];
|
||||||
|
form.decision_id = decisions.value[0]?.id ?? null;
|
||||||
form.send_auto_mail = true;
|
form.send_auto_mail = true;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
|
||||||
() => form.due_date,
|
|
||||||
(due_date) => {
|
|
||||||
if (due_date) {
|
|
||||||
let date = new Date(form.due_date).toLocaleDateString("en-CA");
|
|
||||||
console.table({ old: due_date, new: date });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// keep contract_uuid synced if the prop changes while the drawer is open
|
|
||||||
watch(
|
watch(
|
||||||
() => props.contractUuid,
|
() => props.contractUuid,
|
||||||
(cu) => {
|
(cu) => {
|
||||||
|
|
@ -69,48 +90,6 @@ watch(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const store = async () => {
|
|
||||||
console.table({
|
|
||||||
due_date: form.due_date,
|
|
||||||
action_id: form.action_id,
|
|
||||||
decision_id: form.decision_id,
|
|
||||||
amount: form.amount,
|
|
||||||
note: form.note,
|
|
||||||
});
|
|
||||||
// Helper to safely format a selected date (Date instance or parsable value) to YYYY-MM-DD
|
|
||||||
const formatDateForSubmit = (value) => {
|
|
||||||
if (!value) return null; // leave empty as null
|
|
||||||
const d = value instanceof Date ? value : new Date(value);
|
|
||||||
if (isNaN(d.getTime())) return null; // invalid date -> null
|
|
||||||
// Avoid timezone shifting by constructing in local time
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`; // matches en-CA style YYYY-MM-DD
|
|
||||||
};
|
|
||||||
|
|
||||||
form
|
|
||||||
.transform((data) => ({
|
|
||||||
...data,
|
|
||||||
due_date: formatDateForSubmit(data.due_date),
|
|
||||||
}))
|
|
||||||
.post(route("clientCase.activity.store", props.client_case), {
|
|
||||||
onSuccess: () => {
|
|
||||||
close();
|
|
||||||
// Preserve selected contract across submissions; reset only user-editable fields
|
|
||||||
form.reset("due_date", "amount", "note");
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
console.log("Validation or server error:", errors);
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
console.log("Request finished processing.");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// When the drawer opens, always sync the current contractUuid into the form,
|
|
||||||
// even if the value hasn't changed (prevents stale/null contract_uuid after reset)
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
|
|
@ -120,14 +99,53 @@ watch(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to read metadata for the currently selected decision
|
const store = async () => {
|
||||||
const currentDecision = () => decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0];
|
const formatDateForSubmit = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
form
|
||||||
|
.transform((data) => ({
|
||||||
|
...data,
|
||||||
|
due_date: formatDateForSubmit(data.due_date),
|
||||||
|
attachment_document_ids:
|
||||||
|
templateAllowsAttachments.value && data.attach_documents
|
||||||
|
? data.attachment_document_ids
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.post(route("clientCase.activity.store", props.client_case), {
|
||||||
|
onSuccess: () => {
|
||||||
|
close();
|
||||||
|
form.reset("due_date", "amount", "note");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDecision = () => {
|
||||||
|
if (!Array.isArray(decisions.value) || decisions.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
|
||||||
|
);
|
||||||
|
};
|
||||||
const showSendAutoMail = () => {
|
const showSendAutoMail = () => {
|
||||||
const d = currentDecision();
|
const d = currentDecision();
|
||||||
return !!(d && d.auto_mail && d.email_template_id);
|
return !!(d && d.auto_mail && d.email_template_id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if the selected template requires a contract
|
const templateAllowsAttachments = computed(() => {
|
||||||
|
const d = currentDecision();
|
||||||
|
const tmpl = d?.email_template || d?.emailTemplate || null;
|
||||||
|
return !!(tmpl && tmpl.allow_attachments);
|
||||||
|
});
|
||||||
|
|
||||||
const autoMailRequiresContract = computed(() => {
|
const autoMailRequiresContract = computed(() => {
|
||||||
const d = currentDecision();
|
const d = currentDecision();
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
|
|
@ -136,16 +154,14 @@ const autoMailRequiresContract = computed(() => {
|
||||||
return types.includes("contract");
|
return types.includes("contract");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disable checkbox when contract is required but none is selected
|
|
||||||
const autoMailDisabled = computed(() => {
|
const autoMailDisabled = computed(() => {
|
||||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
||||||
});
|
});
|
||||||
|
|
||||||
const autoMailDisabledHint = computed(() => {
|
const autoMailDisabledHint = computed(() => {
|
||||||
return autoMailDisabled.value ? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo." : "";
|
return autoMailDisabled.value
|
||||||
|
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
|
||||||
|
: "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// If disabled, force the flag off to avoid accidental queue attempts
|
|
||||||
watch(
|
watch(
|
||||||
() => autoMailDisabled.value,
|
() => autoMailDisabled.value,
|
||||||
(disabled) => {
|
(disabled) => {
|
||||||
|
|
@ -154,7 +170,106 @@ watch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isToday = (val) => {
|
||||||
|
try {
|
||||||
|
if (!val) return false;
|
||||||
|
let d;
|
||||||
|
if (val instanceof Date) {
|
||||||
|
d = val;
|
||||||
|
} else if (typeof val === "string") {
|
||||||
|
// Normalize common MySQL timestamp 'YYYY-MM-DD HH:mm:ss' for Safari/iOS
|
||||||
|
const s = val.includes("T") ? val : val.replace(" ", "T");
|
||||||
|
d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
// Fallback: parse manually as local date
|
||||||
|
const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||||
|
if (m) {
|
||||||
|
const [_, yy, mm, dd, hh, mi, ss] = m;
|
||||||
|
d = new Date(
|
||||||
|
Number(yy),
|
||||||
|
Number(mm) - 1,
|
||||||
|
Number(dd),
|
||||||
|
Number(hh),
|
||||||
|
Number(mi),
|
||||||
|
Number(ss || "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof val === "number") {
|
||||||
|
d = new Date(val);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isNaN(d.getTime())) return false;
|
||||||
|
const today = new Date();
|
||||||
|
return (
|
||||||
|
d.getFullYear() === today.getFullYear() &&
|
||||||
|
d.getMonth() === today.getMonth() &&
|
||||||
|
d.getDate() === today.getDate()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const docsSource = computed(() => {
|
||||||
|
if (Array.isArray(props.documents)) {
|
||||||
|
return props.documents;
|
||||||
|
}
|
||||||
|
const propsVal = page?.props?.value || {};
|
||||||
|
return Array.isArray(propsVal.documents) ? propsVal.documents : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableContractDocs = computed(() => {
|
||||||
|
if (!form.contract_uuid) return [];
|
||||||
|
const docs = docsSource.value;
|
||||||
|
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
|
||||||
|
if (!props.phoneMode) return all;
|
||||||
|
return all.filter((d) => {
|
||||||
|
const mime = (d.mime_type || "").toLowerCase();
|
||||||
|
const isImage =
|
||||||
|
mime.startsWith("image/") ||
|
||||||
|
["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].includes(
|
||||||
|
(d.extension || "").toLowerCase()
|
||||||
|
);
|
||||||
|
return isImage && isToday(d.created_at || d.createdAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageContracts = computed(() => {
|
||||||
|
if (Array.isArray(props.contracts)) {
|
||||||
|
return props.contracts;
|
||||||
|
}
|
||||||
|
const propsVal = page?.props?.value || {};
|
||||||
|
return Array.isArray(propsVal.contracts) ? propsVal.contracts : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.phoneMode,
|
||||||
|
() => templateAllowsAttachments.value,
|
||||||
|
() => form.contract_uuid,
|
||||||
|
() => form.decision_id,
|
||||||
|
() => availableContractDocs.value.length,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
if (!props.phoneMode) return;
|
||||||
|
if (!templateAllowsAttachments.value) return;
|
||||||
|
if (!form.contract_uuid) return;
|
||||||
|
const docs = availableContractDocs.value;
|
||||||
|
if (docs.length === 0) return;
|
||||||
|
form.attach_documents = true;
|
||||||
|
if (
|
||||||
|
!Array.isArray(form.attachment_document_ids) ||
|
||||||
|
form.attachment_document_ids.length === 0
|
||||||
|
) {
|
||||||
|
form.attachment_document_ids = docs.map((d) => d.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal :show="show" @close="close">
|
<DialogModal :show="show" @close="close">
|
||||||
<template #title>Dodaj aktivnost</template>
|
<template #title>Dodaj aktivnost</template>
|
||||||
|
|
@ -167,9 +282,9 @@ watch(
|
||||||
id="activityAction"
|
id="activityAction"
|
||||||
ref="activityActionSelect"
|
ref="activityActionSelect"
|
||||||
v-model="form.action_id"
|
v-model="form.action_id"
|
||||||
|
:disabled="!actions || !actions.length"
|
||||||
>
|
>
|
||||||
<option v-for="a in actions" :value="a.id">{{ a.name }}</option>
|
<option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||||
<!-- ... -->
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
|
|
@ -179,9 +294,9 @@ watch(
|
||||||
id="activityDecision"
|
id="activityDecision"
|
||||||
ref="activityDecisionSelect"
|
ref="activityDecisionSelect"
|
||||||
v-model="form.decision_id"
|
v-model="form.decision_id"
|
||||||
|
:disabled="!decisions || !decisions.length"
|
||||||
>
|
>
|
||||||
<option v-for="d in decisions" :value="d.id">{{ d.name }}</option>
|
<option v-for="d in decisions" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||||
<!-- ... -->
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
|
|
@ -225,13 +340,61 @@ watch(
|
||||||
v-model="form.send_auto_mail"
|
v-model="form.send_auto_mail"
|
||||||
:disabled="autoMailDisabled"
|
:disabled="autoMailDisabled"
|
||||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
/>
|
||||||
<span>Send auto email</span>
|
<span>Send auto email</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="autoMailDisabled" class="mt-1 text-xs text-amber-600">
|
<p v-if="autoMailDisabled" class="mt-1 text-xs text-amber-600">
|
||||||
{{ autoMailDisabledHint }}
|
{{ autoMailDisabledHint }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="checkbox" v-model="form.attach_documents" />
|
||||||
|
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="form.attach_documents"
|
||||||
|
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-gray-600 mb-2">
|
||||||
|
Izberite dokumente, ki bodo poslani kot priponke:
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
||||||
|
<div v-if="c.uuid === form.contract_uuid">
|
||||||
|
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||||
|
Pogodba {{ c.reference }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="doc in availableContractDocs"
|
||||||
|
:key="doc.uuid || doc.id"
|
||||||
|
class="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="doc.id"
|
||||||
|
v-model="form.attachment_document_ids"
|
||||||
|
/>
|
||||||
|
<span>{{ doc.original_name || doc.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400"
|
||||||
|
>({{ doc.extension?.toUpperCase() || "" }},
|
||||||
|
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="availableContractDocs.length === 0"
|
||||||
|
class="text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
Ni dokumentov, povezanih s to pogodbo.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import ContractTable from "./Partials/ContractTable.vue";
|
||||||
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
|
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
|
||||||
import ActivityTable from "./Partials/ActivityTable.vue";
|
import ActivityTable from "./Partials/ActivityTable.vue";
|
||||||
import DocumentsTable from "@/Components/DocumentsTable.vue";
|
import DocumentsTable from "@/Components/DocumentsTable.vue";
|
||||||
|
import DocumentEditDialog from "@/Components/DocumentEditDialog.vue";
|
||||||
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
|
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
|
||||||
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
|
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
|
||||||
import { classifyDocument } from "@/Services/documents";
|
import { classifyDocument } from "@/Services/documents";
|
||||||
|
|
@ -46,6 +47,21 @@ const onUploaded = () => {
|
||||||
router.reload({ only: ["documents"] });
|
router.reload({ only: ["documents"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Document edit dialog state
|
||||||
|
const showDocEdit = ref(false)
|
||||||
|
const editingDoc = ref(null)
|
||||||
|
const openDocEdit = (doc) => {
|
||||||
|
editingDoc.value = doc
|
||||||
|
showDocEdit.value = true
|
||||||
|
}
|
||||||
|
const closeDocEdit = () => {
|
||||||
|
showDocEdit.value = false
|
||||||
|
editingDoc.value = null
|
||||||
|
}
|
||||||
|
const onDocSaved = () => {
|
||||||
|
router.reload({ only: ['documents'] })
|
||||||
|
}
|
||||||
|
|
||||||
const viewer = ref({ open: false, src: "", title: "" });
|
const viewer = ref({ open: false, src: "", title: "" });
|
||||||
const openViewer = (doc) => {
|
const openViewer = (doc) => {
|
||||||
const kind = classifyDocument(doc);
|
const kind = classifyDocument(doc);
|
||||||
|
|
@ -337,6 +353,7 @@ const submitAttachSegment = () => {
|
||||||
<DocumentsTable
|
<DocumentsTable
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
@view="openViewer"
|
@view="openViewer"
|
||||||
|
@edit="openDocEdit"
|
||||||
:download-url-builder="
|
:download-url-builder="
|
||||||
(doc) => {
|
(doc) => {
|
||||||
const isContractDoc = (doc?.documentable_type || '')
|
const isContractDoc = (doc?.documentable_type || '')
|
||||||
|
|
@ -365,6 +382,14 @@ const submitAttachSegment = () => {
|
||||||
:post-url="route('clientCase.document.store', client_case)"
|
:post-url="route('clientCase.document.store', client_case)"
|
||||||
:contracts="contracts"
|
:contracts="contracts"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditDialog
|
||||||
|
:show="showDocEdit"
|
||||||
|
:client_case_uuid="client_case.uuid"
|
||||||
|
:document="editingDoc"
|
||||||
|
:contracts="contracts"
|
||||||
|
@close="closeDocEdit"
|
||||||
|
@saved="onDocSaved"
|
||||||
|
/>
|
||||||
<DocumentViewerDialog
|
<DocumentViewerDialog
|
||||||
:show="viewer.open"
|
:show="viewer.open"
|
||||||
:src="viewer.src"
|
:src="viewer.src"
|
||||||
|
|
@ -386,6 +411,8 @@ const submitAttachSegment = () => {
|
||||||
:client_case="client_case"
|
:client_case="client_case"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
:contract-uuid="activityContractUuid"
|
:contract-uuid="activityContractUuid"
|
||||||
|
:documents="documents"
|
||||||
|
:contracts="contracts"
|
||||||
/>
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="confirmDelete.show"
|
:show="confirmDelete.show"
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,13 @@ function activityActionLine(a) {
|
||||||
const drawerAddActivity = ref(false);
|
const drawerAddActivity = ref(false);
|
||||||
const activityContractUuid = ref(null);
|
const activityContractUuid = ref(null);
|
||||||
const openDrawerAddActivity = (c = null) => {
|
const openDrawerAddActivity = (c = null) => {
|
||||||
activityContractUuid.value = c?.uuid ?? null;
|
if (c && c.uuid) {
|
||||||
|
activityContractUuid.value = c.uuid;
|
||||||
|
} else if (Array.isArray(props.contracts) && props.contracts.length === 1) {
|
||||||
|
activityContractUuid.value = props.contracts[0]?.uuid ?? null;
|
||||||
|
} else {
|
||||||
|
activityContractUuid.value = null;
|
||||||
|
}
|
||||||
drawerAddActivity.value = true;
|
drawerAddActivity.value = true;
|
||||||
};
|
};
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
|
|
@ -530,6 +536,9 @@ const clientSummary = computed(() => {
|
||||||
:client_case="client_case"
|
:client_case="client_case"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
:contract-uuid="activityContractUuid"
|
:contract-uuid="activityContractUuid"
|
||||||
|
:phone-mode="true"
|
||||||
|
:documents="documents"
|
||||||
|
:contracts="contracts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">
|
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,9 @@
|
||||||
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
|
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
|
||||||
// client-case / documents
|
// client-case / documents
|
||||||
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
|
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
|
||||||
|
Route::patch('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'updateDocument'])
|
||||||
|
->withoutScopedBindings()
|
||||||
|
->name('clientCase.document.update');
|
||||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
||||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
||||||
Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete');
|
Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete');
|
||||||
|
|
|
||||||
103
tests/Feature/EmailAttachmentsTest.php
Normal file
103
tests/Feature/EmailAttachmentsTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Action;
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Decision;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\EmailLog;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EmailAttachmentsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_activity_with_attachments_queues_email_with_attachments(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
// Auth with baseline permissions
|
||||||
|
$perm = Permission::firstOrCreate(['slug' => 'read'], ['name' => 'Read']);
|
||||||
|
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
|
||||||
|
$role->permissions()->syncWithoutDetaching([$perm->id]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->roles()->syncWithoutDetaching([$role->id]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Minimal domain graph: case -> contract
|
||||||
|
$case = ClientCase::factory()->create();
|
||||||
|
// Create a person email that is eligible to receive auto mails for the CLIENT's person
|
||||||
|
$case->load('client.person');
|
||||||
|
\App\Models\Email::factory()->create([
|
||||||
|
'person_id' => $case->client->person->id,
|
||||||
|
'receive_auto_mails' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
$action = Action::factory()->create();
|
||||||
|
$decision = Decision::factory()->create(['auto_mail' => true]);
|
||||||
|
$action->decisions()->attach($decision->id);
|
||||||
|
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||||
|
|
||||||
|
// Template allowing attachments and requiring contract entity context
|
||||||
|
$template = EmailTemplate::create([
|
||||||
|
'name' => 'Attach OK',
|
||||||
|
'key' => 'attach-ok',
|
||||||
|
'subject_template' => 'Subj',
|
||||||
|
'html_template' => '<p>Hello</p>',
|
||||||
|
'text_template' => 'Hello',
|
||||||
|
'entity_types' => ['contract'],
|
||||||
|
'allow_attachments' => true,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
// Bind template to decision
|
||||||
|
\DB::table('decisions')->where('id', $decision->id)->update(['email_template_id' => $template->id]);
|
||||||
|
|
||||||
|
// Seed a document belonging to the contract
|
||||||
|
Storage::disk('public')->put('docs/a.txt', 'abc');
|
||||||
|
$doc = new Document([
|
||||||
|
'uuid' => (string) \Str::uuid(),
|
||||||
|
'name' => 'a.txt',
|
||||||
|
'original_name' => 'a.txt',
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => 'docs/a.txt',
|
||||||
|
'file_name' => 'a.txt',
|
||||||
|
'extension' => 'txt',
|
||||||
|
'mime_type' => 'text/plain',
|
||||||
|
'size' => 3,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
$contract->documents()->save($doc);
|
||||||
|
|
||||||
|
// Exercise: create activity with attachment_document_ids
|
||||||
|
$response = $this->post(route('clientCase.activity.store', ['client_case' => $case->uuid]), [
|
||||||
|
'action_id' => $action->id,
|
||||||
|
'decision_id' => $decision->id,
|
||||||
|
'contract_uuid' => $contract->uuid,
|
||||||
|
'send_auto_mail' => true,
|
||||||
|
'attachment_document_ids' => [$doc->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
// Validate activity created and email queued with attachments
|
||||||
|
$activity = Activity::latest()->first();
|
||||||
|
$this->assertNotNull($activity);
|
||||||
|
$this->assertEquals($action->id, $activity->action_id);
|
||||||
|
$this->assertEquals($decision->id, $activity->decision_id);
|
||||||
|
|
||||||
|
$log = EmailLog::latest()->first();
|
||||||
|
$this->assertNotNull($log);
|
||||||
|
$this->assertIsArray($log->attachments);
|
||||||
|
$this->assertCount(1, $log->attachments);
|
||||||
|
$this->assertEquals('docs/a.txt', $log->attachments[0]['path'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/Feature/UpdateDocumentTest.php
Normal file
75
tests/Feature/UpdateDocumentTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function makeCaseWithContractAndDoc(): array
|
||||||
|
{
|
||||||
|
$case = ClientCase::factory()->create();
|
||||||
|
$contract = Contract::factory()->for($case, 'clientCase')->create();
|
||||||
|
$doc = Document::factory()->create();
|
||||||
|
// Assign the document to the case initially
|
||||||
|
$doc->documentable_type = ClientCase::class;
|
||||||
|
$doc->documentable_id = $case->id;
|
||||||
|
$doc->save();
|
||||||
|
|
||||||
|
return [$case, $contract, $doc];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reassigns document to a contract within the same case', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
[$case, $contract, $doc] = makeCaseWithContractAndDoc();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('clientCase.document.update', [$case->uuid, $doc->uuid]), [
|
||||||
|
'contract_uuid' => $contract->uuid,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$doc->refresh();
|
||||||
|
expect($doc->documentable_type)->toBe(Contract::class)
|
||||||
|
->and($doc->documentable_id)->toBe($contract->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns validation error when assigning to a contract from another case', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
// Source case
|
||||||
|
[$caseA, $contractA, $doc] = makeCaseWithContractAndDoc();
|
||||||
|
// Different case and its contract
|
||||||
|
$caseB = ClientCase::factory()->create();
|
||||||
|
$contractB = Contract::factory()->for($caseB, 'clientCase')->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('clientCase.show', $caseA->uuid))
|
||||||
|
->patch(route('clientCase.document.update', [$caseA->uuid, $doc->uuid]), [
|
||||||
|
'contract_uuid' => $contractB->uuid,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('contract_uuid');
|
||||||
|
|
||||||
|
$doc->refresh();
|
||||||
|
// Still belongs to the original case
|
||||||
|
expect($doc->documentable_type)->toBe(ClientCase::class)
|
||||||
|
->and($doc->documentable_id)->toBe($caseA->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps or moves document to the case when contract_uuid is null/empty', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
[$case, $contract, $doc] = makeCaseWithContractAndDoc();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('clientCase.show', $case->uuid))
|
||||||
|
->patch(route('clientCase.document.update', [$case->uuid, $doc->uuid]), [
|
||||||
|
// Simulate select "— Brez —" which typically posts an empty string
|
||||||
|
'contract_uuid' => '',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$doc->refresh();
|
||||||
|
expect($doc->documentable_type)->toBe(ClientCase::class)
|
||||||
|
->and($doc->documentable_id)->toBe($case->id);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user