commit 20adb1bd1eb1690e9016f34084d6ad3ddf9678f7 Author: Kelin Rescue Hub Date: Sat Apr 4 13:13:00 2026 -0400 Initial import of animal_management module diff --git a/Events.php b/Events.php new file mode 100644 index 0000000..636215a --- /dev/null +++ b/Events.php @@ -0,0 +1,182 @@ +sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('animal_management')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('AnimalManagementModule.base', 'Animals'), + 'group' => 'modules', + 'url' => $space->createUrl('/animal_management/animals/index'), + 'icon' => '', + 'sortOrder' => 110, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'animal_management' + && Yii::$app->controller->id === 'animals' + ), + ]); + } + + public static function onRescueSettingsMenuInit($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('animal_management')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('AnimalManagementModule.base', 'Animal Management'), + 'url' => $space->createUrl('/animal_management/settings'), + 'sortOrder' => 300, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'animal_management' + && Yii::$app->controller->id === 'settings' + ), + ]); + } + + public static function onSpaceAdminMenuInitFallback($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('animal_management')) { + return; + } + + if ($space->moduleManager->isEnabled('rescue_foundation') || !$space->isAdmin()) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('AnimalManagementModule.base', 'Animal Management'), + 'group' => 'admin', + 'url' => $space->createUrl('/animal_management/settings'), + 'icon' => '', + 'sortOrder' => 600, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'animal_management' + && Yii::$app->controller->id === 'settings' + ), + ]); + } + + public static function onPostAfterSave($event): void + { + $post = $event->sender ?? null; + if (!$post instanceof Post) { + return; + } + + if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + return; + } + + $message = trim((string)$post->message); + if ($message === '' || stripos($message, '#gallery') === false) { + return; + } + + $content = $post->content; + $container = $content->container ?? null; + if (!$container instanceof Space) { + return; + } + + if (!$container->moduleManager->isEnabled('animal_management')) { + return; + } + + $attachedFiles = File::findByRecord($post); + if (empty($attachedFiles)) { + return; + } + + $imageFiles = []; + foreach ($attachedFiles as $file) { + if (!$file instanceof File) { + continue; + } + + if (strpos((string)$file->mime_type, 'image/') !== 0) { + continue; + } + + $imageFiles[] = $file; + } + + if (empty($imageFiles)) { + return; + } + + $animals = Animal::find() + ->where(['contentcontainer_id' => $container->contentcontainer_id]) + ->all(); + + if (empty($animals)) { + return; + } + + $matchedAnimals = []; + $searchableMessage = strtolower(strip_tags($message)); + foreach ($animals as $animal) { + $uid = strtolower(trim((string)$animal->animal_uid)); + if ($uid === '') { + continue; + } + + if (strpos($searchableMessage, $uid) !== false) { + $matchedAnimals[] = $animal; + } + } + + if (empty($matchedAnimals)) { + return; + } + + $createdBy = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + foreach ($matchedAnimals as $animal) { + foreach ($imageFiles as $file) { + $exists = AnimalGalleryItem::findOne([ + 'animal_id' => (int)$animal->id, + 'file_id' => (int)$file->id, + ]); + + if ($exists instanceof AnimalGalleryItem) { + continue; + } + + $item = new AnimalGalleryItem(); + $item->animal_id = (int)$animal->id; + $item->file_id = (int)$file->id; + $item->source_post_id = (int)$post->id; + $item->source_type = 'post'; + $item->created_by = $createdBy; + $item->save(); + } + + if ($container instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $container); + } + } + } +} diff --git a/Module.php b/Module.php new file mode 100644 index 0000000..b964fdd --- /dev/null +++ b/Module.php @@ -0,0 +1,37 @@ +moduleManager->isEnabled('rescue_foundation')) { + return $container->createUrl('/rescue_foundation/settings'); + } + + return $container->createUrl('/animal_management/settings'); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ba4462 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Animal Management Module + +Animal intake, profile management, medical/progress tracking, gallery integration, and transfer workflows for rescue spaces in HumHub. + +## Requirements + +- HumHub 1.14+ +- `rescue_foundation` module available in the same HumHub instance + +## Installation + +Clone into the HumHub modules path using the folder name `animal_management`: + +```bash +git clone https://gitea.kelinreij.duckdns.org/humhub-modules/animal-management.git /var/www/localhost/htdocs/protected/modules/animal_management +``` + +Enable the module in HumHub (Marketplace / Modules UI), then run migrations: + +```bash +php /var/www/localhost/htdocs/protected/yii migrate/up --include-module-migrations=1 --interactive=0 +``` + +## Notes + +- Transfer notifications rely on HumHub queue processing. +- If you run in Docker, execute commands inside the HumHub app container. diff --git a/components/UrlRule.php b/components/UrlRule.php new file mode 100644 index 0000000..b79ca87 --- /dev/null +++ b/components/UrlRule.php @@ -0,0 +1,51 @@ + 'animal_management', + 'class' => 'humhub\\modules\\animal_management\\Module', + 'namespace' => 'humhub\\modules\\animal_management', + 'urlManagerRules' => [ + ['class' => 'humhub\\modules\\animal_management\\components\\UrlRule'], + ], + 'events' => [ + [Menu::class, Menu::EVENT_INIT, [Events::class, 'onSpaceMenuInit']], + ['humhub\\modules\\rescue_foundation\\widgets\\RescueSettingsMenu', 'rescueSettingsMenuInit', [Events::class, 'onRescueSettingsMenuInit']], + [HeaderControlsMenu::class, HeaderControlsMenu::EVENT_INIT, [Events::class, 'onSpaceAdminMenuInitFallback']], + [Post::class, Post::EVENT_AFTER_INSERT, [Events::class, 'onPostAfterSave']], + [Post::class, Post::EVENT_AFTER_UPDATE, [Events::class, 'onPostAfterSave']], + ], +]; diff --git a/controllers/AnimalsController.php b/controllers/AnimalsController.php new file mode 100644 index 0000000..8979c4b --- /dev/null +++ b/controllers/AnimalsController.php @@ -0,0 +1,1710 @@ + VerbFilter::class, + 'actions' => [ + 'delete' => ['post'], + 'add-medical-visit' => ['post'], + 'add-progress-update' => ['post'], + 'add-gallery-images' => ['post'], + 'remove-gallery-image' => ['post'], + 'transfer-respond' => ['post'], + 'transfer-complete' => ['post'], + 'transfer-cancel' => ['post'], + ], + ]; + + return $behaviors; + } + + protected function getAccessRules() + { + return [ + [ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN, Space::USERGROUP_MODERATOR], 'actions' => ['create', 'edit', 'delete', 'transfer', 'add-medical-visit', 'add-medical-visit-inline', 'edit-medical-visit', 'add-progress-update', 'add-progress-update-inline', 'edit-progress-update', 'add-gallery-images', 'remove-gallery-image', 'transfer-respond', 'transfer-complete', 'transfer-cancel']], + ]; + } + + public function actionIndex() + { + $queryValue = trim((string)Yii::$app->request->get('q', '')); + $statusFilter = trim((string)Yii::$app->request->get('status', '')); + $speciesFilter = trim((string)Yii::$app->request->get('species', '')); + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::syncSpaceAnimalGalleries($this->contentContainer); + } + $viewMode = trim((string)Yii::$app->request->get('view', 'tiles')); + if (!in_array($viewMode, ['tiles', 'table'], true)) { + $viewMode = 'tiles'; + } + + $sortKey = trim((string)Yii::$app->request->get('sort', 'updated_at')); + $sortDirection = strtolower(trim((string)Yii::$app->request->get('direction', 'desc'))) === 'asc' ? 'asc' : 'desc'; + + $availableSorts = [ + 'animal_uid' => 'animal_uid', + 'name' => 'name', + 'species' => 'species', + 'status' => 'status', + 'updated_at' => 'updated_at', + 'last_medical' => 'last_medical', + ]; + + if (!isset($availableSorts[$sortKey])) { + $sortKey = 'updated_at'; + } + + $availableColumns = [ + 'animal_uid' => Yii::t('AnimalManagementModule.base', 'ID'), + 'name' => Yii::t('AnimalManagementModule.base', 'Name'), + 'species' => Yii::t('AnimalManagementModule.base', 'Species'), + 'status' => Yii::t('AnimalManagementModule.base', 'Status'), + 'last_medical' => Yii::t('AnimalManagementModule.base', 'Last Medical Visit'), + 'updated_at' => Yii::t('AnimalManagementModule.base', 'Updated'), + ]; + + $defaultColumns = ['animal_uid', 'name', 'species', 'status', 'last_medical', 'updated_at']; + $colsRaw = Yii::$app->request->get('cols', implode(',', $defaultColumns)); + + if (is_array($colsRaw)) { + $selectedColumns = array_values(array_intersect(array_keys($availableColumns), $colsRaw)); + } else { + $selectedColumns = array_values(array_intersect(array_keys($availableColumns), array_map('trim', explode(',', (string)$colsRaw)))); + } + + if (empty($selectedColumns)) { + $selectedColumns = $defaultColumns; + } + + if (!in_array('name', $selectedColumns, true)) { + $selectedColumns[] = 'name'; + } + + $query = Animal::find() + ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + + if ($queryValue !== '') { + $query->andWhere([ + 'or', + ['like', 'animal_uid', $queryValue], + ['like', 'name', $queryValue], + ['like', 'species', $queryValue], + ['like', 'breed', $queryValue], + ]); + } + + if ($statusFilter !== '' && isset(Animal::statusOptions()[$statusFilter])) { + $query->andWhere(['status' => $statusFilter]); + } + + if ($speciesFilter !== '') { + $query->andWhere(['species' => $speciesFilter]); + } + + if ($sortKey !== 'last_medical') { + $sortColumn = $availableSorts[$sortKey]; + $query->orderBy([ + $sortColumn => $sortDirection === 'asc' ? SORT_ASC : SORT_DESC, + 'id' => SORT_DESC, + ]); + } else { + $query->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC]); + } + + $animals = $query->all(); + + $speciesOptions = Animal::find() + ->select('species') + ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]) + ->andWhere(['not', ['species' => null]]) + ->andWhere(['!=', 'species', '']) + ->distinct() + ->orderBy(['species' => SORT_ASC]) + ->column(); + + $speciesOptions = array_values(array_filter(array_map(static function ($species) { + return trim((string)$species); + }, $speciesOptions))); + + $latestMedicalVisitByAnimal = []; + $animalIds = array_map(static function (Animal $animal): int { + return (int)$animal->id; + }, $animals); + + $animalImageUrls = $this->resolveAnimalImageUrls($animalIds, ['profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'], false); + $tileFieldOverrides = $this->resolveDisplayFieldOverrides($animalIds, 'tile_display_fields'); + + if (!empty($animalIds)) { + $visits = AnimalMedicalVisit::find() + ->where(['animal_id' => $animalIds]) + ->orderBy(['animal_id' => SORT_ASC, 'visit_at' => SORT_DESC, 'id' => SORT_DESC]) + ->all(); + + foreach ($visits as $visit) { + $animalId = (int)$visit->animal_id; + if (!isset($latestMedicalVisitByAnimal[$animalId])) { + $latestMedicalVisitByAnimal[$animalId] = $visit; + } + } + } + + if ($sortKey === 'last_medical') { + usort($animals, static function (Animal $a, Animal $b) use ($latestMedicalVisitByAnimal, $sortDirection): int { + $aVisit = $latestMedicalVisitByAnimal[(int)$a->id] ?? null; + $bVisit = $latestMedicalVisitByAnimal[(int)$b->id] ?? null; + + $aTs = $aVisit ? strtotime((string)$aVisit->visit_at) : 0; + $bTs = $bVisit ? strtotime((string)$bVisit->visit_at) : 0; + if ($aTs === false) { + $aTs = 0; + } + if ($bTs === false) { + $bTs = 0; + } + + $cmp = $aTs <=> $bTs; + if ($cmp === 0) { + $cmp = strcmp((string)$a->updated_at, (string)$b->updated_at); + } + + return $sortDirection === 'asc' ? $cmp : -$cmp; + }); + } + + $incomingTransfers = AnimalTransfer::find() + ->where(['to_contentcontainer_id' => $this->contentContainer->contentcontainer_id]) + ->andWhere(['status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]]) + ->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC]) + ->all(); + + $outgoingTransfers = AnimalTransfer::find() + ->where(['from_contentcontainer_id' => $this->contentContainer->contentcontainer_id]) + ->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC]) + ->limit(30) + ->all(); + + $transferAnimalIds = []; + foreach (array_merge($incomingTransfers, $outgoingTransfers) as $transfer) { + if ($transfer instanceof AnimalTransfer) { + $transferAnimalIds[] = (int)$transfer->animal_id; + } + } + + $transferAnimalImageUrls = $this->resolveAnimalImageUrls( + $transferAnimalIds, + ['cover_image_url', 'profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'], + true + ); + + return $this->render('index', [ + 'animals' => $animals, + 'queryValue' => $queryValue, + 'statusFilter' => $statusFilter, + 'speciesFilter' => $speciesFilter, + 'viewMode' => $viewMode, + 'sortKey' => $sortKey, + 'sortDirection' => $sortDirection, + 'availableColumns' => $availableColumns, + 'selectedColumns' => $selectedColumns, + 'speciesOptions' => $speciesOptions, + 'latestMedicalVisitByAnimal' => $latestMedicalVisitByAnimal, + 'animalImageUrls' => $animalImageUrls, + 'tileFields' => $this->getTileFieldSettings(), + 'tileFieldOverrides' => $tileFieldOverrides, + 'space' => $this->contentContainer, + 'canManage' => $this->canManageAnimals(), + 'incomingTransfers' => $incomingTransfers, + 'outgoingTransfers' => $outgoingTransfers, + 'transferAnimalImageUrls' => $transferAnimalImageUrls, + ]); + } + + public function actionView(int $id) + { + $animal = $this->findAnimal($id); + $canManage = $this->canManageAnimals(); + + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer); + } + + $medicalVisitsQuery = $animal->getMedicalVisits(); + $medicalSchema = Yii::$app->db->schema->getTableSchema(AnimalMedicalVisit::tableName(), true); + if ($medicalSchema !== null && isset($medicalSchema->columns['post_to_animal_feed'])) { + $medicalVisitsQuery->andWhere(['post_to_animal_feed' => 1]); + } + $medicalVisits = $medicalVisitsQuery->orderBy(['visit_at' => SORT_DESC, 'id' => SORT_DESC])->all(); + + $progressUpdatesQuery = $animal->getProgressUpdates(); + $progressSchema = Yii::$app->db->schema->getTableSchema(AnimalProgressUpdate::tableName(), true); + if ($progressSchema !== null && isset($progressSchema->columns['post_to_animal_feed'])) { + $progressUpdatesQuery->andWhere(['post_to_animal_feed' => 1]); + } + $progressUpdates = $progressUpdatesQuery->orderBy(['update_at' => SORT_DESC, 'id' => SORT_DESC])->all(); + $transfers = $animal->getTransfers()->orderBy(['created_at' => SORT_DESC, 'id' => SORT_DESC])->all(); + $transferEvents = $animal->getTransferEvents()->orderBy(['id' => SORT_DESC])->limit(100)->all(); + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + $customFieldValues = $animal->getCustomFieldDisplayValues($canManage); + $animalImageUrls = $this->resolveAnimalImageUrls([(int)$animal->id], ['cover_image_url', 'image_url', 'photo_url', 'photo'], false); + $animalCoverImageUrl = trim((string)($animalImageUrls[(int)$animal->id] ?? '')); + $detailHeroFields = $this->getDetailHeroFieldSettings(); + $heroOverrides = $this->resolveDisplayFieldOverrides([(int)$animal->id], 'hero_display_fields'); + if (!empty($heroOverrides[(int)$animal->id])) { + $detailHeroFields = $heroOverrides[(int)$animal->id]; + } + + return $this->render('view', [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'canManage' => $canManage, + 'medicalVisits' => $medicalVisits, + 'progressUpdates' => $progressUpdates, + 'transfers' => $transfers, + 'transferEvents' => $transferEvents, + 'galleryItems' => $galleryItems, + 'customFieldValues' => $customFieldValues, + 'animalCoverImageUrl' => $animalCoverImageUrl, + 'detailHeroFields' => $detailHeroFields, + ]); + } + + public function actionMedicalVisits(int $id) + { + $animal = $this->findAnimal($id); + + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer); + } + $canManage = $this->canManageAnimals(); + + $this->ensureMedicalMediaFieldDefinition(); + $medicalVisitForm = new AnimalMedicalVisitForm(['animal' => $animal]); + $medicalVisits = $animal->getMedicalVisits()->orderBy(['visit_at' => SORT_DESC, 'id' => SORT_DESC])->all(); + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + return $this->render('medical-visits', [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'canManage' => $canManage, + 'medicalVisitForm' => $medicalVisitForm, + 'medicalVisits' => $medicalVisits, + 'galleryItems' => $galleryItems, + ]); + } + + public function actionProgressUpdates(int $id) + { + $animal = $this->findAnimal($id); + + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer); + } + $canManage = $this->canManageAnimals(); + + $progressUpdateForm = new AnimalProgressUpdateForm(['animal' => $animal]); + $progressUpdates = $animal->getProgressUpdates()->orderBy(['update_at' => SORT_DESC, 'id' => SORT_DESC])->all(); + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + return $this->render('progress-updates', [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'canManage' => $canManage, + 'progressUpdateForm' => $progressUpdateForm, + 'progressUpdates' => $progressUpdates, + 'galleryItems' => $galleryItems, + ]); + } + + public function actionCreate() + { + $model = new AnimalForm(['contentContainer' => $this->contentContainer]); + + if (Yii::$app->request->isPost) { + $model->load(Yii::$app->request->post()); + $model->coverImageFile = UploadedFile::getInstanceByName('AnimalForm[coverImageFile]') + ?: UploadedFile::getInstance($model, 'coverImageFile'); + $model->profileImageFile = UploadedFile::getInstanceByName('AnimalForm[profileImageFile]') + ?: UploadedFile::getInstance($model, 'profileImageFile'); + } + + if (Yii::$app->request->isPost && $model->save()) { + $savedAnimal = $model->getAnimal(); + if ($savedAnimal instanceof Animal) { + $this->syncPrimaryImagesToGallery($savedAnimal); + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($savedAnimal, $this->contentContainer); + } + } + + $this->view->success(Yii::t('AnimalManagementModule.base', 'Animal created.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + return $this->render('create', [ + 'model' => $model, + 'space' => $this->contentContainer, + 'isEdit' => false, + 'animal' => null, + ]); + } + + public function actionEdit(int $id) + { + $animal = $this->findAnimal($id); + + $model = new AnimalForm(['contentContainer' => $this->contentContainer]); + $model->setAnimal($animal); + + if (Yii::$app->request->isPost) { + $model->load(Yii::$app->request->post()); + $model->coverImageFile = UploadedFile::getInstanceByName('AnimalForm[coverImageFile]') + ?: UploadedFile::getInstance($model, 'coverImageFile'); + $model->profileImageFile = UploadedFile::getInstanceByName('AnimalForm[profileImageFile]') + ?: UploadedFile::getInstance($model, 'profileImageFile'); + } + + if (Yii::$app->request->isPost && $model->save()) { + $savedAnimal = $model->getAnimal(); + if ($savedAnimal instanceof Animal) { + $this->syncPrimaryImagesToGallery($savedAnimal); + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($savedAnimal, $this->contentContainer); + } + } + + $this->view->success(Yii::t('AnimalManagementModule.base', 'Animal updated.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id])); + } + + return $this->render('create', [ + 'model' => $model, + 'space' => $this->contentContainer, + 'isEdit' => true, + 'animal' => $animal, + ]); + } + + public function actionDelete(int $id) + { + if (!$this->canManageAnimals()) { + throw new ForbiddenHttpException('You are not allowed to delete animals.'); + } + + $animal = $this->findAnimal($id); + $localGalleryPaths = $this->collectAnimalLocalGalleryImagePaths((int)$animal->id); + + if ($animal->delete() === false) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not delete animal.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $id])); + } + + $deletedFiles = 0; + foreach ($localGalleryPaths as $path) { + $absolutePath = Yii::getAlias('@webroot') . $path; + if (!is_file($absolutePath)) { + continue; + } + + if (@unlink($absolutePath)) { + $deletedFiles++; + } + } + + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::syncSpaceAnimalGalleries($this->contentContainer); + } + + if ($deletedFiles > 0) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Animal deleted and {count} gallery image(s) removed.', ['count' => $deletedFiles])); + } else { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Animal deleted.')); + } + + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + public function actionTransfer(int $id) + { + $animal = $this->findAnimal($id); + + $form = new TransferRequestForm([ + 'animal' => $animal, + 'sourceSpace' => $this->contentContainer, + ]); + + if ($form->load(Yii::$app->request->post()) && $form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request sent.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id])); + } + + return $this->render('transfer', [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'model' => $form, + ]); + } + + public function actionAddGalleryImages(int $id) + { + $animal = $this->findAnimal($id); + $uploadedFiles = UploadedFile::getInstancesByName('galleryImages'); + $maxUploadCount = 10; + + if (empty($uploadedFiles)) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'No gallery images were selected.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery'); + } + + if (count($uploadedFiles) > $maxUploadCount) { + $uploadedFiles = array_slice($uploadedFiles, 0, $maxUploadCount); + $this->view->info(Yii::t('AnimalManagementModule.base', 'Only the first {count} selected images were processed.', ['count' => $maxUploadCount])); + } + + $allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions()); + $allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes()); + $maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES; + $existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all(); + $existingHashes = []; + foreach ($existingItems as $existingItem) { + $existingUrl = trim((string)$existingItem->getImageUrl()); + if ($existingUrl === '') { + continue; + } + + $hash = $this->computeImageContentHash($existingUrl); + if ($hash !== null) { + $existingHashes[$hash] = true; + } + } + + $added = 0; + foreach ($uploadedFiles as $uploadedFile) { + if (!$uploadedFile instanceof UploadedFile) { + continue; + } + + $extension = strtolower((string)$uploadedFile->extension); + $mimeType = strtolower((string)$uploadedFile->type); + + if (!in_array($extension, $allowedExtensions, true)) { + continue; + } + + if ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true)) { + continue; + } + + if ($uploadedFile->size > $maxBytes) { + continue; + } + + $storedPath = $this->storeGalleryUpload($animal, $uploadedFile); + if ($storedPath === null) { + continue; + } + + $storedHash = $this->computeImageContentHash($storedPath); + + $exactExists = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id, 'file_path' => $storedPath]) + ->exists(); + + if ($exactExists || ($storedHash !== null && isset($existingHashes[$storedHash]))) { + $absolute = Yii::getAlias('@webroot') . $storedPath; + if (is_file($absolute)) { + @unlink($absolute); + } + continue; + } + + $item = new AnimalGalleryItem(); + $item->animal_id = (int)$animal->id; + $item->file_path = $storedPath; + $item->source_type = 'upload'; + $item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + if ($item->save()) { + $added++; + if ($storedHash !== null) { + $existingHashes[$storedHash] = true; + } + } + } + + if ($added > 0) { + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer); + } + $this->view->success(Yii::t('AnimalManagementModule.base', '{count} image(s) added to gallery.', ['count' => $added])); + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'No gallery images were added. Check image type/size requirements.')); + } + + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery'); + } + + public function actionRemoveGalleryImage(int $id, int $galleryId) + { + $animal = $this->findAnimal($id); + $item = AnimalGalleryItem::findOne(['id' => $galleryId, 'animal_id' => (int)$animal->id]); + if (!$item instanceof AnimalGalleryItem) { + throw new NotFoundHttpException('Gallery image not found.'); + } + + $path = trim((string)$item->file_path); + $deletePhysicalFile = false; + if ($path !== '' && substr($path, 0, 1) === '/') { + $stillReferencedByGallery = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id, 'file_path' => $path]) + ->andWhere(['!=', 'id', (int)$item->id]) + ->exists(); + + $stillReferencedByPrimaryImage = $this->isPrimaryImagePathReferenced((int)$animal->id, $path); + $deletePhysicalFile = !$stillReferencedByGallery && !$stillReferencedByPrimaryImage; + } + + if ($item->delete() !== false) { + if ($this->contentContainer instanceof Space) { + GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer); + } + if ($deletePhysicalFile) { + $absolute = Yii::getAlias('@webroot') . $path; + if (is_file($absolute)) { + @unlink($absolute); + } + } + + $this->view->success(Yii::t('AnimalManagementModule.base', 'Gallery image removed.')); + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not remove gallery image.')); + } + + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery'); + } + + public function actionAddMedicalVisit(int $id) + { + $animal = $this->findAnimal($id); + $this->ensureMedicalMediaFieldDefinition(); + $form = new AnimalMedicalVisitForm(['animal' => $animal]); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view')); + + if ($form->load(Yii::$app->request->post())) { + $this->applyMedicalMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit added.')); + } else { + $message = Yii::t('AnimalManagementModule.base', 'Could not save medical visit.'); + if ($form->hasErrors()) { + $message .= ' ' . implode(' ', array_values($form->getFirstErrors())); + } + $this->view->error($message); + } + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not save medical visit.')); + } + + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + + public function actionAddMedicalVisitInline(int $id) + { + $animal = $this->findAnimal($id); + $this->ensureMedicalMediaFieldDefinition(); + $form = new AnimalMedicalVisitForm(['animal' => $animal]); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'medical-visits')); + $isInline = (int)Yii::$app->request->get('inline', 1) === 1; + + if ($form->load(Yii::$app->request->post())) { + $this->applyMedicalMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit added.')); + if ($isInline) { + return $this->renderAjax('inline-edit-result', [ + 'collapseId' => 'medical-add-inline', + 'refreshSelectors' => ['#animal-medical-panel', '#animal-gallery-panel'], + ]); + } + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + } + + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + $params = [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'model' => $form, + 'returnTo' => $returnTo, + 'galleryItems' => $galleryItems, + 'isInline' => $isInline, + ]; + + if ($isInline) { + return $this->renderAjax('add-medical-visit', $params); + } + + return $this->render('add-medical-visit', $params); + } + + public function actionEditMedicalVisit(int $id, int $visitId) + { + $animal = $this->findAnimal($id); + $visit = $animal->getMedicalVisits()->andWhere(['id' => $visitId])->one(); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view')); + $isInline = (int)Yii::$app->request->get('inline', 0) === 1; + + if (!$visit instanceof \humhub\modules\animal_management\models\AnimalMedicalVisit) { + throw new NotFoundHttpException('Medical visit not found.'); + } + + $this->ensureMedicalMediaFieldDefinition(); + $form = new AnimalMedicalVisitForm(['animal' => $animal]); + $form->setMedicalVisit($visit); + + if ($form->load(Yii::$app->request->post())) { + $this->applyMedicalMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit updated.')); + if ($isInline) { + return $this->renderAjax('inline-edit-result', [ + 'collapseId' => 'medical-edit-inline-' . (int)$visit->id, + 'refreshSelectors' => ['#animal-medical-panel', '#animal-gallery-panel'], + ]); + } + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + } + + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + $params = [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'model' => $form, + 'medicalVisit' => $visit, + 'returnTo' => $returnTo, + 'galleryItems' => $galleryItems, + 'isInline' => $isInline, + ]; + + if ($isInline) { + return $this->renderAjax('edit-medical-visit', $params); + } + + return $this->render('edit-medical-visit', $params); + } + + public function actionAddProgressUpdate(int $id) + { + $animal = $this->findAnimal($id); + $form = new AnimalProgressUpdateForm(['animal' => $animal]); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view')); + + if ($form->load(Yii::$app->request->post())) { + $this->applyProgressMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update added.')); + } else { + $message = Yii::t('AnimalManagementModule.base', 'Could not save progress update.'); + if ($form->hasErrors()) { + $message .= ' ' . implode(' ', array_values($form->getFirstErrors())); + } + $this->view->error($message); + } + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not save progress update.')); + } + + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + + public function actionAddProgressUpdateInline(int $id) + { + $animal = $this->findAnimal($id); + $form = new AnimalProgressUpdateForm(['animal' => $animal]); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'progress-updates')); + $isInline = (int)Yii::$app->request->get('inline', 1) === 1; + + if ($form->load(Yii::$app->request->post())) { + $this->applyProgressMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update added.')); + if ($isInline) { + return $this->renderAjax('inline-edit-result', [ + 'collapseId' => 'progress-add-inline', + 'refreshSelectors' => ['#animal-progress-panel', '#animal-gallery-panel'], + ]); + } + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + } + + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + $params = [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'model' => $form, + 'returnTo' => $returnTo, + 'galleryItems' => $galleryItems, + 'isInline' => $isInline, + ]; + + if ($isInline) { + return $this->renderAjax('add-progress-update', $params); + } + + return $this->render('add-progress-update', $params); + } + + public function actionEditProgressUpdate(int $id, int $updateId) + { + $animal = $this->findAnimal($id); + $update = $animal->getProgressUpdates()->andWhere(['id' => $updateId])->one(); + $returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view')); + $isInline = (int)Yii::$app->request->get('inline', 0) === 1; + + if (!$update instanceof \humhub\modules\animal_management\models\AnimalProgressUpdate) { + throw new NotFoundHttpException('Progress update not found.'); + } + + $form = new AnimalProgressUpdateForm(['animal' => $animal]); + $form->setProgressUpdate($update); + + if ($form->load(Yii::$app->request->post())) { + $this->applyProgressMediaInput($form, $animal); + + if ($form->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update updated.')); + if ($isInline) { + return $this->renderAjax('inline-edit-result', [ + 'collapseId' => 'progress-edit-inline-' . (int)$update->id, + 'refreshSelectors' => ['#animal-progress-panel', '#animal-gallery-panel'], + ]); + } + return $this->redirectToAnimalPage((int)$animal->id, $returnTo); + } + + Yii::warning([ + 'message' => 'Progress update save failed.', + 'animal_id' => (int)$animal->id, + 'progress_update_id' => (int)$update->id, + 'is_inline' => $isInline, + 'errors' => $form->getErrors(), + ], 'animal_management.progress_media'); + } elseif (Yii::$app->request->isPost) { + Yii::warning([ + 'message' => 'Progress update form load failed on POST.', + 'animal_id' => (int)$animal->id, + 'progress_update_id' => (int)$update->id, + 'is_inline' => $isInline, + 'post_keys' => array_keys((array)Yii::$app->request->post()), + ], 'animal_management.progress_media'); + } + + $galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all(); + + $params = [ + 'space' => $this->contentContainer, + 'animal' => $animal, + 'model' => $form, + 'progressUpdate' => $update, + 'returnTo' => $returnTo, + 'galleryItems' => $galleryItems, + 'isInline' => $isInline, + ]; + + if ($isInline) { + return $this->renderAjax('edit-progress-update', $params); + } + + return $this->render('edit-progress-update', $params); + } + + public function actionTransferRespond(int $id, string $decision) + { + $transfer = AnimalTransfer::findOne([ + 'id' => $id, + 'to_contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if (!$transfer instanceof AnimalTransfer) { + throw new NotFoundHttpException('Transfer request not found.'); + } + + if ($transfer->status !== AnimalTransfer::STATUS_REQUESTED) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'This transfer can no longer be responded to.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + if ($decision === 'accept') { + if ($transfer->markAccepted()) { + $originator = Yii::$app->user->getIdentity(); + TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_ACCEPTED, $originator); + $this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request accepted.')); + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not accept transfer request.')); + } + } elseif ($decision === 'decline') { + if ($transfer->markDeclined()) { + $originator = Yii::$app->user->getIdentity(); + TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_DECLINED, $originator); + $animal = $transfer->animal; + if ($animal instanceof Animal && $animal->status === Animal::STATUS_TRANSFER_PENDING) { + $hasOpenTransfers = AnimalTransfer::find() + ->where(['animal_id' => $animal->id, 'status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]]) + ->exists(); + + if (!$hasOpenTransfers) { + $animal->status = Animal::STATUS_ACTIVE; + $animal->save(false, ['status', 'updated_at']); + } + } + $this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request declined.')); + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not decline transfer request.')); + } + } else { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Unknown transfer decision.')); + } + + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + public function actionTransferComplete(int $id) + { + $transfer = AnimalTransfer::findOne([ + 'id' => $id, + 'to_contentcontainer_id' => $this->contentContainer->contentcontainer_id, + 'status' => AnimalTransfer::STATUS_ACCEPTED, + ]); + + if (!$transfer instanceof AnimalTransfer) { + throw new NotFoundHttpException('Accepted transfer not found.'); + } + + $animal = $transfer->animal; + if (!$animal instanceof Animal) { + throw new NotFoundHttpException('Animal not found.'); + } + + $animal->contentcontainer_id = $this->contentContainer->contentcontainer_id; + $animal->status = Animal::STATUS_ACTIVE; + if (!$animal->save(false, ['contentcontainer_id', 'status', 'updated_at'])) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not move animal ownership.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + if (!$transfer->markCompleted()) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not complete transfer.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + $originator = Yii::$app->user->getIdentity(); + TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_COMPLETED, $originator); + + $openTransfers = AnimalTransfer::find() + ->where(['animal_id' => $animal->id]) + ->andWhere(['status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]]) + ->andWhere(['!=', 'id', $transfer->id]) + ->all(); + + foreach ($openTransfers as $openTransfer) { + if (!$openTransfer instanceof AnimalTransfer) { + continue; + } + + $openTransfer->markCancelled(Yii::t('AnimalManagementModule.base', 'Transfer cancelled because another transfer was completed.')); + } + + $this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer completed. Animal ownership moved to this rescue.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id])); + } + + public function actionTransferCancel(int $id) + { + $transfer = AnimalTransfer::findOne([ + 'id' => $id, + 'from_contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if (!$transfer instanceof AnimalTransfer) { + throw new NotFoundHttpException('Transfer request not found.'); + } + + if (!in_array($transfer->status, [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED], true)) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'This transfer can no longer be cancelled.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + if (!$transfer->markCancelled(Yii::t('AnimalManagementModule.base', 'Transfer cancelled by source rescue.'))) { + $this->view->error(Yii::t('AnimalManagementModule.base', 'Could not cancel transfer request.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + $originator = Yii::$app->user->getIdentity(); + TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_CANCELLED, $originator); + + $animal = $transfer->animal; + if ($animal instanceof Animal && $animal->status === Animal::STATUS_TRANSFER_PENDING) { + $hasOpenTransfers = AnimalTransfer::find() + ->where(['animal_id' => $animal->id, 'status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]]) + ->exists(); + + if (!$hasOpenTransfers) { + $animal->status = Animal::STATUS_ACTIVE; + $animal->save(false, ['status', 'updated_at']); + } + } + + $this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request cancelled.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); + } + + private function findAnimal(int $id): Animal + { + $animal = Animal::findOne(['id' => $id, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + if (!$animal instanceof Animal) { + throw new NotFoundHttpException('Animal not found.'); + } + + return $animal; + } + + private function collectAnimalLocalGalleryImagePaths(int $animalId): array + { + if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + return []; + } + + $items = AnimalGalleryItem::find()->where(['animal_id' => $animalId])->all(); + $paths = []; + + foreach ($items as $item) { + $path = trim((string)$item->file_path); + if ($path === '' || substr($path, 0, 1) !== '/') { + continue; + } + + $paths[$path] = true; + } + + return array_keys($paths); + } + + private function redirectToAnimalPage(int $animalId, string $returnTo): Response + { + if ($returnTo === 'medical-visits') { + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/medical-visits', ['id' => $animalId])); + } + + if ($returnTo === 'progress-updates') { + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/progress-updates', ['id' => $animalId])); + } + + return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animalId])); + } + + private function canManageAnimals(): bool + { + if ($this->contentContainer->can(ManageAnimals::class)) { + return true; + } + + if ($this->contentContainer instanceof Space && $this->contentContainer->isAdmin()) { + return true; + } + + return false; + } + + private function getTileFieldSettings(): array + { + $settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer); + $raw = $settings->get('tileFields', json_encode(DisplaySettingsForm::DEFAULT_TILE_FIELDS)); + return $this->normalizeDisplayFields($raw, DisplaySettingsForm::DEFAULT_TILE_FIELDS); + } + + private function getDetailHeroFieldSettings(): array + { + $settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer); + $raw = $settings->get('detailHeroFields', json_encode(DisplaySettingsForm::DEFAULT_DETAIL_FIELDS)); + return $this->normalizeDisplayFields($raw, DisplaySettingsForm::DEFAULT_DETAIL_FIELDS); + } + + private function normalizeDisplayFields($raw, array $default): array + { + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $default; + } + $raw = $decoded; + } + + if (!is_array($raw)) { + return $default; + } + + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $normalized = []; + foreach ($raw as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + return !empty($normalized) ? $normalized : $default; + } + + private function resolveAnimalImageUrls(array $animalIds, array $imageFieldOrder = [], bool $allowGalleryFallback = false): array + { + $animalIds = array_values(array_unique(array_map('intval', $animalIds))); + if (empty($animalIds)) { + return []; + } + + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return []; + } + + if (empty($imageFieldOrder)) { + $imageFieldOrder = ['cover_image_url', 'profile_image_url', 'photo_url', 'image_url', 'profile_image', 'photo']; + } + + $definitions = RescueFieldDefinition::find() + ->select(['id', 'field_key']) + ->where([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $imageFieldOrder, + 'is_active' => 1, + ]) + ->all(); + + if (empty($definitions)) { + return []; + } + + $definitionPriority = []; + foreach ($definitions as $definition) { + $priority = array_search((string)$definition->field_key, $imageFieldOrder, true); + if ($priority === false) { + $priority = 999; + } + + $definitionPriority[(int)$definition->id] = (int)$priority; + } + + if (empty($definitionPriority)) { + return []; + } + + $valueRows = AnimalFieldValue::find() + ->where(['animal_id' => $animalIds, 'field_definition_id' => array_keys($definitionPriority)]) + ->all(); + + $imageUrls = []; + $chosenPriorityByAnimal = []; + foreach ($valueRows as $valueRow) { + $animalId = (int)$valueRow->animal_id; + $valueText = trim((string)$valueRow->value_text); + if ($valueText === '') { + continue; + } + + $priority = $definitionPriority[(int)$valueRow->field_definition_id] ?? 999; + if (!isset($chosenPriorityByAnimal[$animalId]) || $priority < $chosenPriorityByAnimal[$animalId]) { + $chosenPriorityByAnimal[$animalId] = $priority; + $imageUrls[$animalId] = $valueText; + } + } + + $missingAnimalIds = []; + foreach ($animalIds as $animalId) { + if (!isset($imageUrls[$animalId])) { + $missingAnimalIds[] = (int)$animalId; + } + } + + if ($allowGalleryFallback && !empty($missingAnimalIds) && Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) !== null) { + $galleryItems = AnimalGalleryItem::find() + ->where(['animal_id' => $missingAnimalIds]) + ->orderBy(['animal_id' => SORT_ASC, 'id' => SORT_DESC]) + ->all(); + + foreach ($galleryItems as $galleryItem) { + $animalId = (int)$galleryItem->animal_id; + if (isset($imageUrls[$animalId])) { + continue; + } + + $url = trim((string)$galleryItem->getImageUrl()); + if ($url === '') { + continue; + } + + $imageUrls[$animalId] = $url; + } + } + + return $imageUrls; + } + + private function resolveDisplayFieldOverrides(array $animalIds, string $fieldKey): array + { + $animalIds = array_values(array_unique(array_map('intval', $animalIds))); + if (empty($animalIds)) { + return []; + } + + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return []; + } + + $definition = RescueFieldDefinition::findOne([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + ]); + + if (!$definition instanceof RescueFieldDefinition) { + return []; + } + + $rows = AnimalFieldValue::find() + ->where(['animal_id' => $animalIds, 'field_definition_id' => (int)$definition->id]) + ->all(); + + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $result = []; + foreach ($rows as $row) { + $raw = trim((string)$row->value_text); + if ($raw === '') { + continue; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + $decoded = array_map('trim', explode(',', $raw)); + } + + $normalized = []; + foreach ($decoded as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + if (!empty($normalized)) { + $result[(int)$row->animal_id] = $normalized; + } + } + + return $result; + } + + private function storeGalleryUpload(Animal $animal, UploadedFile $file): ?string + { + $random = Yii::$app->security->generateRandomString(8); + $extension = strtolower((string)$file->extension); + $fileName = 'gallery-' . time() . '-' . $random . '.' . $extension; + + $candidateDirs = [ + '/uploads/animal-management/gallery/' . (int)$animal->id, + '/uploads/animal-management-runtime/gallery/' . (int)$animal->id, + '/uploads/animal-media/gallery/' . (int)$animal->id, + '/assets/animal-management-runtime/gallery/' . (int)$animal->id, + ]; + + foreach ($candidateDirs as $relativeDir) { + $absoluteDir = Yii::getAlias('@webroot') . $relativeDir; + try { + FileHelper::createDirectory($absoluteDir, 0775, true); + } catch (\Throwable $e) { + Yii::warning([ + 'message' => $e->getMessage(), + 'relative_dir' => $relativeDir, + 'absolute_dir' => $absoluteDir, + ], 'animal_management.gallery_upload_dir'); + continue; + } + + if (!is_dir($absoluteDir) || !is_writable($absoluteDir)) { + continue; + } + + $absolutePath = $absoluteDir . '/' . $fileName; + if ($file->saveAs($absolutePath)) { + return $relativeDir . '/' . $fileName; + } + } + + Yii::warning([ + 'message' => 'Could not save uploaded gallery file in any candidate directory.', + 'animal_id' => (int)$animal->id, + 'file_name' => $fileName, + 'candidate_dirs' => $candidateDirs, + ], 'animal_management.gallery_upload_dir'); + + return null; + } + + private function computeImageContentHash(string $pathOrUrl): ?string + { + $pathOrUrl = trim($pathOrUrl); + if ($pathOrUrl === '') { + return null; + } + + $absolutePath = $pathOrUrl; + if (substr($absolutePath, 0, 1) === '/') { + $absolutePath = Yii::getAlias('@webroot') . $absolutePath; + } + + if (!is_file($absolutePath) || !is_readable($absolutePath)) { + return null; + } + + $hash = @hash_file('sha1', $absolutePath); + return is_string($hash) && $hash !== '' ? $hash : null; + } + + private function syncPrimaryImagesToGallery(Animal $animal): void + { + if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null + ) { + return; + } + + $definitions = RescueFieldDefinition::find() + ->select(['id', 'field_key']) + ->where([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => ['cover_image_url', 'profile_image_url'], + ]) + ->all(); + + if (empty($definitions)) { + return; + } + + $byDefinitionId = []; + foreach ($definitions as $definition) { + $byDefinitionId[(int)$definition->id] = (string)$definition->field_key; + } + + $fieldRows = AnimalFieldValue::find() + ->where([ + 'animal_id' => (int)$animal->id, + 'field_definition_id' => array_keys($byDefinitionId), + ]) + ->all(); + + if (empty($fieldRows)) { + return; + } + + $existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all(); + $existingByPath = []; + $existingHashes = []; + foreach ($existingItems as $existingItem) { + $path = trim((string)$existingItem->file_path); + if ($path !== '') { + $existingByPath[$path] = true; + } + + $url = trim((string)$existingItem->getImageUrl()); + if ($url !== '') { + $existingByPath[$url] = true; + } + + $hash = $this->computeImageContentHash($url !== '' ? $url : $path); + if ($hash !== null) { + $existingHashes[$hash] = true; + } + } + + foreach ($fieldRows as $fieldRow) { + $imagePath = trim((string)$fieldRow->value_text); + if ($imagePath === '') { + continue; + } + + if (!$this->isResolvableImageUrl($imagePath)) { + continue; + } + + if (isset($existingByPath[$imagePath])) { + continue; + } + + $hash = $this->computeImageContentHash($imagePath); + if ($hash !== null && isset($existingHashes[$hash])) { + continue; + } + + $fieldKey = $byDefinitionId[(int)$fieldRow->field_definition_id] ?? 'upload'; + $item = new AnimalGalleryItem(); + $item->animal_id = (int)$animal->id; + $item->file_path = $imagePath; + $item->source_type = $fieldKey === 'cover_image_url' ? 'cover' : 'profile'; + $item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + + if ($item->save()) { + $existingByPath[$imagePath] = true; + if ($hash !== null) { + $existingHashes[$hash] = true; + } + } else { + Yii::warning($item->getErrors(), 'animal_management.primary_image_gallery_sync'); + } + } + } + + private function isPrimaryImagePathReferenced(int $animalId, string $path): bool + { + if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return false; + } + + $definitionIds = RescueFieldDefinition::find() + ->select('id') + ->where([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => ['cover_image_url', 'profile_image_url'], + ]) + ->column(); + + if (empty($definitionIds)) { + return false; + } + + return AnimalFieldValue::find() + ->where([ + 'animal_id' => $animalId, + 'field_definition_id' => $definitionIds, + 'value_text' => $path, + ]) + ->exists(); + } + + private function isResolvableImageUrl(string $pathOrUrl): bool + { + $pathOrUrl = trim($pathOrUrl); + if ($pathOrUrl === '') { + return false; + } + + if (preg_match('/^https?:\/\//i', $pathOrUrl)) { + return true; + } + + if (substr($pathOrUrl, 0, 1) !== '/') { + return false; + } + + $absolutePath = Yii::getAlias('@webroot') . $pathOrUrl; + return is_file($absolutePath) && is_readable($absolutePath); + } + + private function applyProgressMediaInput(AnimalProgressUpdateForm $form, Animal $animal): void + { + $removeProgressMedia = (int)Yii::$app->request->post('removeProgressMedia', 0) === 1; + if ($removeProgressMedia) { + $form->customFields['media_reference'] = ''; + return; + } + + $selectedGalleryPath = trim((string)Yii::$app->request->post('progressMediaGalleryPath', '')); + if ($selectedGalleryPath === '') { + $selectedGalleryPath = trim((string)Yii::$app->request->post('AnimalProgressUpdateForm.progressMediaGalleryPath', '')); + } + + if ($selectedGalleryPath === '') { + $formPayload = Yii::$app->request->post('AnimalProgressUpdateForm', []); + if (is_array($formPayload) && isset($formPayload['progressMediaGalleryPath'])) { + $selectedGalleryPath = trim((string)$formPayload['progressMediaGalleryPath']); + } + } + + $uploadedMedia = UploadedFile::getInstanceByName('progressMediaUpload'); + if (!$uploadedMedia instanceof UploadedFile) { + $uploadedMedia = UploadedFile::getInstanceByName('AnimalProgressUpdateForm[progressMediaUpload]'); + } + + if (!$uploadedMedia instanceof UploadedFile) { + $uploadedCandidates = UploadedFile::getInstancesByName('progressMediaUpload'); + if (!empty($uploadedCandidates) && $uploadedCandidates[0] instanceof UploadedFile) { + $uploadedMedia = $uploadedCandidates[0]; + } + } + + if ($uploadedMedia instanceof UploadedFile) { + $allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions()); + $allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes()); + $maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES; + + $extension = strtolower((string)$uploadedMedia->extension); + $mimeType = strtolower((string)$uploadedMedia->type); + + if (!in_array($extension, $allowedExtensions, true) + || ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true)) + || $uploadedMedia->size > $maxBytes + ) { + $form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Progress media image must be a valid image within size limits.')); + return; + } + + $storedPath = $this->storeGalleryUpload($animal, $uploadedMedia); + if ($storedPath === null) { + Yii::warning([ + 'message' => 'storeGalleryUpload returned null for progress media.', + 'animal_id' => (int)$animal->id, + 'progress_update_id' => $form->progressUpdate instanceof AnimalProgressUpdate ? (int)$form->progressUpdate->id : null, + 'uploaded_media_name' => (string)$uploadedMedia->name, + ], 'animal_management.progress_media'); + $form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not upload progress media image.')); + return; + } + + $this->addImagePathToGalleryIfMissing($animal, $storedPath, 'progress'); + $form->customFields['media_reference'] = $storedPath; + return; + } + + if ( + $selectedGalleryPath !== '' + && (preg_match('/^https?:\/\//i', $selectedGalleryPath) || substr($selectedGalleryPath, 0, 1) === '/') + ) { + $this->addImagePathToGalleryIfMissing($animal, $selectedGalleryPath, 'progress'); + $form->customFields['media_reference'] = $selectedGalleryPath; + return; + } + + // Preserve existing media reference when no explicit media action was posted. + if ($form->progressUpdate instanceof AnimalProgressUpdate) { + $existingMedia = $this->resolveProgressMediaReferenceFromRecord($form->progressUpdate); + if ($existingMedia !== '') { + $form->customFields['media_reference'] = $existingMedia; + } + } + } + + private function resolveProgressMediaReferenceFromRecord(AnimalProgressUpdate $record): string + { + foreach ($record->getCustomFieldDisplayValues(true) as $customField) { + if ((string)($customField['field_key'] ?? '') !== 'media_reference') { + continue; + } + + $value = trim((string)($customField['value'] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function applyMedicalMediaInput(AnimalMedicalVisitForm $form, Animal $animal): void + { + if ((int)Yii::$app->request->post('removeMedicalMedia', 0) === 1) { + $form->customFields['medical_media_reference'] = ''; + $form->customFields['media_reference'] = ''; + return; + } + + $selectedGalleryPath = trim((string)Yii::$app->request->post('medicalMediaGalleryPath', '')); + $uploadedMedia = UploadedFile::getInstanceByName('medicalMediaUpload'); + + if ($uploadedMedia instanceof UploadedFile) { + $allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions()); + $allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes()); + $maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES; + + $extension = strtolower((string)$uploadedMedia->extension); + $mimeType = strtolower((string)$uploadedMedia->type); + + if (!in_array($extension, $allowedExtensions, true) + || ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true)) + || $uploadedMedia->size > $maxBytes + ) { + $form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Medical media image must be a valid image within size limits.')); + return; + } + + $storedPath = $this->storeGalleryUpload($animal, $uploadedMedia); + if ($storedPath === null) { + $form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not upload medical media image.')); + return; + } + + $this->addImagePathToGalleryIfMissing($animal, $storedPath, 'medical'); + $form->customFields['medical_media_reference'] = $storedPath; + $form->customFields['media_reference'] = $storedPath; + return; + } + + if ( + $selectedGalleryPath !== '' + && (preg_match('/^https?:\/\//i', $selectedGalleryPath) || substr($selectedGalleryPath, 0, 1) === '/') + ) { + $this->addImagePathToGalleryIfMissing($animal, $selectedGalleryPath, 'medical'); + $form->customFields['medical_media_reference'] = $selectedGalleryPath; + $form->customFields['media_reference'] = $selectedGalleryPath; + } + } + + private function ensureMedicalMediaFieldDefinition(): void + { + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + ) { + return; + } + + $definition = RescueFieldDefinition::findOne([ + 'module_id' => 'animal_management', + 'field_key' => 'medical_media_reference', + ]); + + if ($definition instanceof RescueFieldDefinition) { + $needsSave = false; + if ((string)$definition->group_key !== 'animal_medical_visit') { + $definition->group_key = 'animal_medical_visit'; + $needsSave = true; + } + if ((int)$definition->is_active !== 1) { + $definition->is_active = 1; + $needsSave = true; + } + if ((string)$definition->visibility !== 'public') { + $definition->visibility = 'public'; + $needsSave = true; + } + if ((string)$definition->label === '') { + $definition->label = 'Media'; + $needsSave = true; + } + if ($needsSave) { + $definition->updated_at = date('Y-m-d H:i:s'); + $definition->save(); + } + return; + } + + $definition = new RescueFieldDefinition(); + $definition->module_id = 'animal_management'; + $definition->group_key = 'animal_medical_visit'; + $definition->field_key = 'medical_media_reference'; + $definition->label = 'Media'; + $definition->input_type = 'text'; + $definition->required = 0; + $definition->is_core = 1; + $definition->is_active = 1; + $definition->visibility = 'public'; + $definition->options = '{}'; + $definition->sort_order = 220; + $definition->created_at = date('Y-m-d H:i:s'); + $definition->updated_at = date('Y-m-d H:i:s'); + $definition->save(); + } + + private function addImagePathToGalleryIfMissing(Animal $animal, string $imagePath, string $sourceType = 'upload'): void + { + $imagePath = trim($imagePath); + if ($imagePath === '') { + return; + } + + $existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all(); + $incomingHash = $this->computeImageContentHash($imagePath); + foreach ($existingItems as $existingItem) { + $existingPath = trim((string)$existingItem->file_path); + $existingUrl = trim((string)$existingItem->getImageUrl()); + + if ($existingPath === $imagePath || $existingUrl === $imagePath) { + return; + } + + if ($incomingHash !== null) { + $existingHash = $this->computeImageContentHash($existingUrl !== '' ? $existingUrl : $existingPath); + if ($existingHash !== null && hash_equals($incomingHash, $existingHash)) { + return; + } + } + } + + if (substr($imagePath, 0, 1) !== '/') { + return; + } + + $item = new AnimalGalleryItem(); + $item->animal_id = (int)$animal->id; + $item->file_path = $imagePath; + $item->source_type = $sourceType; + $item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + $item->save(); + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php new file mode 100644 index 0000000..93cbf5b --- /dev/null +++ b/controllers/SettingsController.php @@ -0,0 +1,58 @@ + [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN]]]; + } + + public function actionIndex() + { + $subNav = null; + if (class_exists(RescueSettingsMenu::class)) { + $subNav = RescueSettingsMenu::widget(['space' => $this->contentContainer]); + } + + $fieldSettingsForm = new FieldDefinitionSettingsForm(); + $fieldSettingsForm->loadRows(); + $displaySettingsForm = new DisplaySettingsForm([ + 'contentContainer' => $this->contentContainer, + ]); + $displaySettingsForm->loadValues(); + + if (Yii::$app->request->post('DisplaySettingsForm') !== null) { + if ($displaySettingsForm->load(Yii::$app->request->post()) && $displaySettingsForm->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Display settings saved.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/settings')); + } + } + + if (Yii::$app->request->post('FieldDefinitionSettingsForm') !== null) { + if ($fieldSettingsForm->load(Yii::$app->request->post()) && $fieldSettingsForm->save()) { + $this->view->success(Yii::t('AnimalManagementModule.base', 'Field settings saved.')); + return $this->redirect($this->contentContainer->createUrl('/animal_management/settings')); + } + } + + $animalCount = Animal::find()->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id])->count(); + + return $this->render('index', [ + 'subNav' => $subNav, + 'animalCount' => (int)$animalCount, + 'fieldSettingsForm' => $fieldSettingsForm, + 'displaySettingsForm' => $displaySettingsForm, + ]); + } +} diff --git a/helpers/DateDisplayHelper.php b/helpers/DateDisplayHelper.php new file mode 100644 index 0000000..9346aa4 --- /dev/null +++ b/helpers/DateDisplayHelper.php @@ -0,0 +1,25 @@ +formatter->asDatetime($timestamp, self::DATETIME_FORMAT); + } +} diff --git a/migrations/m260401_210000_initial.php b/migrations/m260401_210000_initial.php new file mode 100644 index 0000000..269e593 --- /dev/null +++ b/migrations/m260401_210000_initial.php @@ -0,0 +1,160 @@ +safeCreateTable('rescue_animal', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'animal_uid' => $this->string(190)->notNull(), + 'name' => $this->string(190)->null(), + 'species' => $this->string(120)->null(), + 'breed' => $this->string(120)->null(), + 'sex' => $this->string(32)->null(), + 'status' => $this->string(32)->notNull()->defaultValue('intake'), + 'in_possession' => $this->boolean()->notNull()->defaultValue(1), + 'location_name' => $this->string(120)->null(), + 'city' => $this->string(120)->null(), + 'state' => $this->string(2)->null(), + 'zip' => $this->string(10)->null(), + 'previous_owner_user_id' => $this->integer()->null(), + 'public_summary' => $this->text()->null(), + 'medical_notes' => $this->text()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('ux_rescue_animal_uid', 'rescue_animal', 'animal_uid', true); + $this->safeCreateIndex('idx_rescue_animal_container', 'rescue_animal', 'contentcontainer_id', false); + $this->safeCreateIndex('idx_rescue_animal_status', 'rescue_animal', 'status', false); + $this->safeAddForeignKey('fk_rescue_animal_container', 'rescue_animal', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + + if ($this->db->getSchema()->getTableSchema('user', true) !== null) { + $this->safeAddForeignKey('fk_rescue_animal_prev_owner', 'rescue_animal', 'previous_owner_user_id', 'user', 'id', 'SET NULL', 'CASCADE'); + } + + $this->safeCreateTable('rescue_animal_transfer', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'from_contentcontainer_id' => $this->integer()->notNull(), + 'to_contentcontainer_id' => $this->integer()->notNull(), + 'requested_by' => $this->integer()->null(), + 'status' => $this->string(32)->notNull()->defaultValue('requested'), + 'request_message' => $this->text()->null(), + 'conditions_text' => $this->text()->null(), + 'responded_at' => $this->dateTime()->null(), + 'completed_at' => $this->dateTime()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_animal_transfer_animal', 'rescue_animal_transfer', 'animal_id', false); + $this->safeCreateIndex('idx_rescue_animal_transfer_status', 'rescue_animal_transfer', 'status', false); + $this->safeAddForeignKey('fk_rescue_animal_transfer_animal', 'rescue_animal_transfer', 'animal_id', 'rescue_animal', 'id', 'CASCADE', 'CASCADE'); + $this->safeAddForeignKey('fk_rescue_animal_transfer_from_container', 'rescue_animal_transfer', 'from_contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + $this->safeAddForeignKey('fk_rescue_animal_transfer_to_container', 'rescue_animal_transfer', 'to_contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + + if ($this->db->getSchema()->getTableSchema('user', true) !== null) { + $this->safeAddForeignKey('fk_rescue_animal_transfer_requested_by', 'rescue_animal_transfer', 'requested_by', 'user', 'id', 'SET NULL', 'CASCADE'); + } + + $this->safeCreateTable('rescue_animal_medical_visit', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'visit_at' => $this->dateTime()->null(), + 'provider_name' => $this->string(190)->null(), + 'notes' => $this->text()->null(), + 'recommendations' => $this->text()->null(), + 'created_by' => $this->integer()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_medical_visit_animal', 'rescue_animal_medical_visit', 'animal_id', false); + $this->safeAddForeignKey('fk_rescue_medical_visit_animal', 'rescue_animal_medical_visit', 'animal_id', 'rescue_animal', 'id', 'CASCADE', 'CASCADE'); + + $this->safeCreateTable('rescue_animal_progress_update', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'update_at' => $this->dateTime()->null(), + 'weight' => $this->string(32)->null(), + 'vitals' => $this->text()->null(), + 'behavior_notes' => $this->text()->null(), + 'meal_plan_changes' => $this->text()->null(), + 'housing_changes' => $this->text()->null(), + 'medical_concerns' => $this->text()->null(), + 'post_to_space_feed' => $this->boolean()->notNull()->defaultValue(0), + 'post_to_animal_feed' => $this->boolean()->notNull()->defaultValue(1), + 'created_by' => $this->integer()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_progress_animal', 'rescue_animal_progress_update', 'animal_id', false); + $this->safeAddForeignKey('fk_rescue_progress_animal', 'rescue_animal_progress_update', 'animal_id', 'rescue_animal', 'id', 'CASCADE', 'CASCADE'); + + $this->seedFieldMetadata(); + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_progress_update'); + $this->safeDropTable('rescue_animal_medical_visit'); + $this->safeDropTable('rescue_animal_transfer'); + $this->safeDropTable('rescue_animal'); + } + + private function seedFieldMetadata(): void + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $createdAt = date('Y-m-d H:i:s'); + $rows = [ + ['name', 'Animal name', 'text', 0, 1, 100], + ['species', 'Species', 'text', 0, 1, 110], + ['breed', 'Breed', 'text', 0, 1, 120], + ['sex', 'Sex', 'text', 0, 1, 130], + ['status', 'Status', 'select', 1, 1, 140], + ['in_possession', 'In possession', 'boolean', 1, 1, 150], + ['location_name', 'Location name', 'text', 0, 1, 160], + ['city', 'City', 'text', 0, 1, 170], + ['state', 'State', 'text', 0, 1, 180], + ['zip', 'ZIP', 'text', 0, 1, 190], + ['public_summary', 'Public summary', 'textarea', 0, 1, 200], + ['medical_notes', 'Medical notes', 'textarea', 0, 1, 210], + ]; + + foreach ($rows as $row) { + [$fieldKey, $label, $inputType, $required, $isCore, $sortOrder] = $row; + $exists = (new \yii\db\Query()) + ->from('rescue_field_definition') + ->where(['module_id' => 'animal_management', 'field_key' => $fieldKey]) + ->exists($this->db); + + if ($exists) { + continue; + } + + $this->insert('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + 'label' => $label, + 'input_type' => $inputType, + 'required' => $required, + 'is_core' => $isCore, + 'is_active' => 1, + 'visibility' => 'public', + 'options' => '{}', + 'sort_order' => $sortOrder, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + } +} diff --git a/migrations/m260402_010000_transfer_event_audit.php b/migrations/m260402_010000_transfer_event_audit.php new file mode 100644 index 0000000..b4e05ae --- /dev/null +++ b/migrations/m260402_010000_transfer_event_audit.php @@ -0,0 +1,63 @@ +safeCreateTable('rescue_animal_transfer_event', [ + 'id' => $this->primaryKey(), + 'transfer_id' => $this->integer()->notNull(), + 'animal_id' => $this->integer()->notNull(), + 'event_type' => $this->string(32)->notNull(), + 'from_status' => $this->string(32)->null(), + 'to_status' => $this->string(32)->null(), + 'message' => $this->text()->null(), + 'metadata_json' => $this->text()->null(), + 'created_by' => $this->integer()->null(), + 'created_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_transfer_event_transfer', 'rescue_animal_transfer_event', 'transfer_id', false); + $this->safeCreateIndex('idx_rescue_transfer_event_animal', 'rescue_animal_transfer_event', 'animal_id', false); + $this->safeCreateIndex('idx_rescue_transfer_event_type', 'rescue_animal_transfer_event', 'event_type', false); + $this->safeCreateIndex('idx_rescue_transfer_event_created_at', 'rescue_animal_transfer_event', 'created_at', false); + + $this->safeAddForeignKey( + 'fk_rescue_transfer_event_transfer', + 'rescue_animal_transfer_event', + 'transfer_id', + 'rescue_animal_transfer', + 'id', + 'CASCADE', + 'CASCADE' + ); + $this->safeAddForeignKey( + 'fk_rescue_transfer_event_animal', + 'rescue_animal_transfer_event', + 'animal_id', + 'rescue_animal', + 'id', + 'CASCADE', + 'CASCADE' + ); + + if ($this->db->getSchema()->getTableSchema('user', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_transfer_event_created_by', + 'rescue_animal_transfer_event', + 'created_by', + 'user', + 'id', + 'SET NULL', + 'CASCADE' + ); + } + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_transfer_event'); + } +} diff --git a/migrations/m260402_020000_animal_field_values.php b/migrations/m260402_020000_animal_field_values.php new file mode 100644 index 0000000..ee13e2f --- /dev/null +++ b/migrations/m260402_020000_animal_field_values.php @@ -0,0 +1,50 @@ +safeCreateTable('rescue_animal_field_value', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'field_definition_id' => $this->integer()->notNull(), + 'value_text' => $this->text()->null(), + 'value_json' => $this->text()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('ux_rescue_animal_field_value_unique', 'rescue_animal_field_value', ['animal_id', 'field_definition_id'], true); + $this->safeCreateIndex('idx_rescue_animal_field_value_animal', 'rescue_animal_field_value', 'animal_id', false); + $this->safeCreateIndex('idx_rescue_animal_field_value_definition', 'rescue_animal_field_value', 'field_definition_id', false); + + $this->safeAddForeignKey( + 'fk_rescue_animal_field_value_animal', + 'rescue_animal_field_value', + 'animal_id', + 'rescue_animal', + 'id', + 'CASCADE', + 'CASCADE' + ); + + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_field_value_definition', + 'rescue_animal_field_value', + 'field_definition_id', + 'rescue_field_definition', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_field_value'); + } +} diff --git a/migrations/m260402_030000_medical_progress_field_values.php b/migrations/m260402_030000_medical_progress_field_values.php new file mode 100644 index 0000000..090203b --- /dev/null +++ b/migrations/m260402_030000_medical_progress_field_values.php @@ -0,0 +1,117 @@ +safeCreateTable('rescue_animal_medical_visit_field_value', [ + 'id' => $this->primaryKey(), + 'medical_visit_id' => $this->integer()->notNull(), + 'field_definition_id' => $this->integer()->notNull(), + 'value_text' => $this->text()->null(), + 'value_json' => $this->text()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex( + 'ux_rescue_medical_visit_field_value_unique', + 'rescue_animal_medical_visit_field_value', + ['medical_visit_id', 'field_definition_id'], + true + ); + $this->safeCreateIndex( + 'idx_rescue_medical_visit_field_value_visit', + 'rescue_animal_medical_visit_field_value', + 'medical_visit_id', + false + ); + $this->safeCreateIndex( + 'idx_rescue_medical_visit_field_value_definition', + 'rescue_animal_medical_visit_field_value', + 'field_definition_id', + false + ); + + $this->safeAddForeignKey( + 'fk_rescue_medical_visit_field_value_visit', + 'rescue_animal_medical_visit_field_value', + 'medical_visit_id', + 'rescue_animal_medical_visit', + 'id', + 'CASCADE', + 'CASCADE' + ); + + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_medical_visit_field_value_definition', + 'rescue_animal_medical_visit_field_value', + 'field_definition_id', + 'rescue_field_definition', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + $this->safeCreateTable('rescue_animal_progress_update_field_value', [ + 'id' => $this->primaryKey(), + 'progress_update_id' => $this->integer()->notNull(), + 'field_definition_id' => $this->integer()->notNull(), + 'value_text' => $this->text()->null(), + 'value_json' => $this->text()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex( + 'ux_rescue_progress_update_field_value_unique', + 'rescue_animal_progress_update_field_value', + ['progress_update_id', 'field_definition_id'], + true + ); + $this->safeCreateIndex( + 'idx_rescue_progress_update_field_value_update', + 'rescue_animal_progress_update_field_value', + 'progress_update_id', + false + ); + $this->safeCreateIndex( + 'idx_rescue_progress_update_field_value_definition', + 'rescue_animal_progress_update_field_value', + 'field_definition_id', + false + ); + + $this->safeAddForeignKey( + 'fk_rescue_progress_update_field_value_update', + 'rescue_animal_progress_update_field_value', + 'progress_update_id', + 'rescue_animal_progress_update', + 'id', + 'CASCADE', + 'CASCADE' + ); + + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_progress_update_field_value_definition', + 'rescue_animal_progress_update_field_value', + 'field_definition_id', + 'rescue_field_definition', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_progress_update_field_value'); + $this->safeDropTable('rescue_animal_medical_visit_field_value'); + } +} diff --git a/migrations/m260402_040000_add_image_profile_fields.php b/migrations/m260402_040000_add_image_profile_fields.php new file mode 100644 index 0000000..d0b0891 --- /dev/null +++ b/migrations/m260402_040000_add_image_profile_fields.php @@ -0,0 +1,61 @@ +db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $createdAt = date('Y-m-d H:i:s'); + $rows = [ + ['cover_image_url', 'Cover image URL', 230], + ['profile_image_url', 'Profile image URL', 240], + ]; + + foreach ($rows as $row) { + [$fieldKey, $label, $sortOrder] = $row; + + $exists = (new \yii\db\Query()) + ->from('rescue_field_definition') + ->where(['module_id' => 'animal_management', 'field_key' => $fieldKey]) + ->exists($this->db); + + if ($exists) { + continue; + } + + $this->insert('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + 'label' => $label, + 'input_type' => 'text', + 'required' => 0, + 'is_core' => 0, + 'is_active' => 1, + 'visibility' => 'public', + 'options' => '{}', + 'sort_order' => $sortOrder, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + } + + public function safeDown() + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $this->delete('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'field_key' => ['cover_image_url', 'profile_image_url'], + 'is_core' => 0, + ]); + } +} diff --git a/migrations/m260402_050000_seed_requirement_default_fields.php b/migrations/m260402_050000_seed_requirement_default_fields.php new file mode 100644 index 0000000..cc63f32 --- /dev/null +++ b/migrations/m260402_050000_seed_requirement_default_fields.php @@ -0,0 +1,160 @@ +db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $createdAt = date('Y-m-d H:i:s'); + + $rows = [ + // Animal profile defaults from requirements + ['animal_profile', 'dob', 'DOB', 'date', 0, 1, 'public', 112], + ['animal_profile', 'age', 'Age', 'text', 0, 1, 'public', 113], + ['animal_profile', 'previous_owner_user_id', 'Previous Owner User ID', 'number', 0, 1, 'internal', 114], + ['animal_profile', 'previous_owner_name', 'Previous Owner Name', 'text', 0, 1, 'internal', 115], + ['animal_profile', 'previous_owner_business_name', 'Previous Owner Business Name', 'text', 0, 1, 'internal', 116], + ['animal_profile', 'previous_owner_street_address', 'Previous Owner Street Address', 'text', 0, 1, 'internal', 117], + ['animal_profile', 'previous_owner_city', 'Previous Owner City', 'text', 0, 1, 'internal', 118], + ['animal_profile', 'previous_owner_state', 'Previous Owner State', 'text', 0, 1, 'internal', 119], + ['animal_profile', 'previous_owner_zip', 'Previous Owner Zip', 'text', 0, 1, 'internal', 120], + ['animal_profile', 'previous_owner_cell_phone', 'Previous Owner Cell Phone', 'text', 0, 1, 'internal', 121], + ['animal_profile', 'previous_owner_business_phone', 'Previous Owner Business Phone', 'text', 0, 1, 'internal', 122], + ['animal_profile', 'previous_owner_email', 'Previous Owner Email', 'text', 0, 1, 'internal', 123], + ['animal_profile', 'lineage', 'Lineage', 'textarea', 0, 1, 'public', 124], + ['animal_profile', 'backstory', 'Backstory', 'textarea', 0, 1, 'public', 125], + ['animal_profile', 'rescue', 'Rescue', 'text', 0, 1, 'internal', 126], + + // Medical visit defaults from requirements + ['animal_medical_visit', 'weight', 'Weight', 'text', 0, 1, 'internal', 210], + ['animal_medical_visit', 'pulse', 'Pulse', 'text', 0, 1, 'internal', 211], + ['animal_medical_visit', 'blood_pressure', 'Blood Pressure', 'text', 0, 1, 'internal', 212], + ['animal_medical_visit', 'oxygen', 'Oxygen', 'text', 0, 1, 'internal', 213], + ['animal_medical_visit', 'chronic_conditions', 'Chronic Conditions', 'textarea', 0, 1, 'internal', 214], + ['animal_medical_visit', 'acute_conditions', 'Acute Conditions', 'textarea', 0, 1, 'internal', 215], + ['animal_medical_visit', 'special_needs', 'Special Needs', 'textarea', 0, 1, 'internal', 216], + ['animal_medical_visit', 'date_of_most_recent_medical_visit', 'Date of Most Recent Medical Visit', 'date', 0, 1, 'internal', 217], + + ['animal_medical_visit', 'physician_name', 'Physician Name', 'text', 0, 1, 'internal', 218], + ['animal_medical_visit', 'physician_business_name', 'Physician Business Name', 'text', 0, 1, 'internal', 219], + ['animal_medical_visit', 'physician_street_address', 'Physician Street Address', 'text', 0, 1, 'internal', 220], + ['animal_medical_visit', 'physician_city', 'Physician City', 'text', 0, 1, 'internal', 221], + ['animal_medical_visit', 'physician_state', 'Physician State', 'text', 0, 1, 'internal', 222], + ['animal_medical_visit', 'physician_zip', 'Physician Zip', 'text', 0, 1, 'internal', 223], + ['animal_medical_visit', 'physician_cell_phone', 'Physician Cell Phone', 'text', 0, 1, 'internal', 224], + ['animal_medical_visit', 'physician_business_phone', 'Physician Business Phone', 'text', 0, 1, 'internal', 225], + ['animal_medical_visit', 'physician_license_number', 'Physician License Number', 'text', 0, 1, 'internal', 226], + + ['animal_medical_visit', 'second_physician_name', 'Second Physician Name', 'text', 0, 1, 'internal', 227], + ['animal_medical_visit', 'second_physician_business_name', 'Second Physician Business Name', 'text', 0, 1, 'internal', 228], + ['animal_medical_visit', 'second_physician_street_address', 'Second Physician Street Address', 'text', 0, 1, 'internal', 229], + ['animal_medical_visit', 'second_physician_city', 'Second Physician City', 'text', 0, 1, 'internal', 230], + ['animal_medical_visit', 'second_physician_state', 'Second Physician State', 'text', 0, 1, 'internal', 231], + ['animal_medical_visit', 'second_physician_zip', 'Second Physician Zip', 'text', 0, 1, 'internal', 232], + ['animal_medical_visit', 'second_physician_cell_phone', 'Second Physician Cell Phone', 'text', 0, 1, 'internal', 233], + ['animal_medical_visit', 'second_physician_business_phone', 'Second Physician Business Phone', 'text', 0, 1, 'internal', 234], + ['animal_medical_visit', 'second_physician_license_number', 'Second Physician License Number', 'text', 0, 1, 'internal', 235], + + ['animal_medical_visit', 'previous_physicians', 'Previous Physician(s)', 'textarea', 0, 1, 'internal', 236], + + // Progress defaults from requirements + ['animal_progress_update', 'progress_notes', 'Notes', 'textarea', 0, 1, 'internal', 310], + ['animal_progress_update', 'routine_updates', 'Updates', 'textarea', 0, 1, 'internal', 311], + ['animal_progress_update', 'media_reference', 'Media', 'text', 0, 1, 'internal', 312], + ]; + + foreach ($rows as $row) { + [$groupKey, $fieldKey, $label, $inputType, $required, $isActive, $visibility, $sortOrder] = $row; + + $exists = (new \yii\db\Query()) + ->from('rescue_field_definition') + ->where(['module_id' => 'animal_management', 'field_key' => $fieldKey]) + ->exists($this->db); + + if ($exists) { + continue; + } + + $this->insert('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'group_key' => $groupKey, + 'field_key' => $fieldKey, + 'label' => $label, + 'input_type' => $inputType, + 'required' => $required, + 'is_core' => 0, + 'is_active' => $isActive, + 'visibility' => $visibility, + 'options' => '{}', + 'sort_order' => $sortOrder, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + } + + public function safeDown() + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $fieldKeys = [ + 'dob', + 'age', + 'previous_owner_user_id', + 'previous_owner_name', + 'previous_owner_business_name', + 'previous_owner_street_address', + 'previous_owner_city', + 'previous_owner_state', + 'previous_owner_zip', + 'previous_owner_cell_phone', + 'previous_owner_business_phone', + 'previous_owner_email', + 'lineage', + 'backstory', + 'rescue', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'second_physician_name', + 'second_physician_business_name', + 'second_physician_street_address', + 'second_physician_city', + 'second_physician_state', + 'second_physician_zip', + 'second_physician_cell_phone', + 'second_physician_business_phone', + 'second_physician_license_number', + 'previous_physicians', + 'progress_notes', + 'routine_updates', + 'media_reference', + ]; + + $this->delete('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'field_key' => $fieldKeys, + 'is_core' => 0, + ]); + } +} diff --git a/migrations/m260402_060000_create_animal_gallery.php b/migrations/m260402_060000_create_animal_gallery.php new file mode 100644 index 0000000..276ac3d --- /dev/null +++ b/migrations/m260402_060000_create_animal_gallery.php @@ -0,0 +1,66 @@ +safeCreateTable('rescue_animal_gallery_item', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'file_path' => $this->string(500)->null(), + 'file_id' => $this->integer()->null(), + 'source_post_id' => $this->integer()->null(), + 'source_type' => $this->string(32)->notNull()->defaultValue('upload'), + 'caption' => $this->text()->null(), + 'created_by' => $this->integer()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_animal_gallery_animal', 'rescue_animal_gallery_item', 'animal_id', false); + $this->safeCreateIndex('idx_rescue_animal_gallery_file', 'rescue_animal_gallery_item', 'file_id', false); + $this->safeCreateIndex('idx_rescue_animal_gallery_post', 'rescue_animal_gallery_item', 'source_post_id', false); + $this->safeCreateIndex('idx_rescue_animal_gallery_created', 'rescue_animal_gallery_item', ['animal_id', 'id'], false); + + $this->safeAddForeignKey( + 'fk_rescue_animal_gallery_animal', + 'rescue_animal_gallery_item', + 'animal_id', + 'rescue_animal', + 'id', + 'CASCADE', + 'CASCADE' + ); + + if ($this->db->getSchema()->getTableSchema('file', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_gallery_file', + 'rescue_animal_gallery_item', + 'file_id', + 'file', + 'id', + 'SET NULL', + 'CASCADE' + ); + } + + if ($this->db->getSchema()->getTableSchema('post', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_gallery_post', + 'rescue_animal_gallery_item', + 'source_post_id', + 'post', + 'id', + 'SET NULL', + 'CASCADE' + ); + } + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_gallery_item'); + } +} diff --git a/migrations/m260402_070000_lock_requirement_default_fields.php b/migrations/m260402_070000_lock_requirement_default_fields.php new file mode 100644 index 0000000..050f418 --- /dev/null +++ b/migrations/m260402_070000_lock_requirement_default_fields.php @@ -0,0 +1,133 @@ +db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $fieldKeys = [ + 'dob', + 'age', + 'previous_owner_user_id', + 'previous_owner_name', + 'previous_owner_business_name', + 'previous_owner_street_address', + 'previous_owner_city', + 'previous_owner_state', + 'previous_owner_zip', + 'previous_owner_cell_phone', + 'previous_owner_business_phone', + 'previous_owner_email', + 'lineage', + 'backstory', + 'rescue', + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'second_physician_name', + 'second_physician_business_name', + 'second_physician_street_address', + 'second_physician_city', + 'second_physician_state', + 'second_physician_zip', + 'second_physician_cell_phone', + 'second_physician_business_phone', + 'second_physician_license_number', + 'previous_physicians', + 'progress_notes', + 'routine_updates', + 'media_reference', + ]; + + $this->update('rescue_field_definition', [ + 'is_core' => 1, + 'is_active' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ], [ + 'module_id' => 'animal_management', + 'field_key' => $fieldKeys, + ]); + } + + public function safeDown() + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $fieldKeys = [ + 'dob', + 'age', + 'previous_owner_user_id', + 'previous_owner_name', + 'previous_owner_business_name', + 'previous_owner_street_address', + 'previous_owner_city', + 'previous_owner_state', + 'previous_owner_zip', + 'previous_owner_cell_phone', + 'previous_owner_business_phone', + 'previous_owner_email', + 'lineage', + 'backstory', + 'rescue', + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'second_physician_name', + 'second_physician_business_name', + 'second_physician_street_address', + 'second_physician_city', + 'second_physician_state', + 'second_physician_zip', + 'second_physician_cell_phone', + 'second_physician_business_phone', + 'second_physician_license_number', + 'previous_physicians', + 'progress_notes', + 'routine_updates', + 'media_reference', + ]; + + $this->update('rescue_field_definition', [ + 'is_core' => 0, + 'updated_at' => date('Y-m-d H:i:s'), + ], [ + 'module_id' => 'animal_management', + 'field_key' => $fieldKeys, + ]); + } +} diff --git a/migrations/m260402_080000_add_display_override_profile_fields.php b/migrations/m260402_080000_add_display_override_profile_fields.php new file mode 100644 index 0000000..4e72fbe --- /dev/null +++ b/migrations/m260402_080000_add_display_override_profile_fields.php @@ -0,0 +1,79 @@ +db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $createdAt = date('Y-m-d H:i:s'); + $rows = [ + ['tile_display_fields', 'Tile Display Fields', 250], + ['hero_display_fields', 'Hero Display Fields', 251], + ]; + + foreach ($rows as $row) { + [$fieldKey, $label, $sortOrder] = $row; + + $existingId = (new \yii\db\Query()) + ->select('id') + ->from('rescue_field_definition') + ->where([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + ]) + ->scalar($this->db); + + if ($existingId) { + $this->update('rescue_field_definition', [ + 'label' => $label, + 'input_type' => 'text', + 'required' => 0, + 'is_core' => 1, + 'is_active' => 1, + 'visibility' => 'internal', + 'sort_order' => $sortOrder, + 'updated_at' => $createdAt, + ], ['id' => (int)$existingId]); + continue; + } + + $this->insert('rescue_field_definition', [ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + 'label' => $label, + 'input_type' => 'text', + 'required' => 0, + 'is_core' => 1, + 'is_active' => 1, + 'visibility' => 'internal', + 'options' => '{}', + 'sort_order' => $sortOrder, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + } + + public function safeDown() + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $this->update('rescue_field_definition', [ + 'is_core' => 0, + 'updated_at' => date('Y-m-d H:i:s'), + ], [ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => ['tile_display_fields', 'hero_display_fields'], + ]); + } +} diff --git a/migrations/m260403_120000_create_animal_gallery_link.php b/migrations/m260403_120000_create_animal_gallery_link.php new file mode 100644 index 0000000..4f014f8 --- /dev/null +++ b/migrations/m260403_120000_create_animal_gallery_link.php @@ -0,0 +1,55 @@ +db->getSchema()->getTableSchema('rescue_animal_gallery_link', true) !== null) { + return; + } + + $this->safeCreateTable('rescue_animal_gallery_link', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'gallery_id' => $this->integer()->notNull(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_animal_gallery_link_animal', 'rescue_animal_gallery_link', 'animal_id', true); + $this->safeCreateIndex('idx_rescue_animal_gallery_link_gallery', 'rescue_animal_gallery_link', 'gallery_id', true); + $this->safeCreateIndex('idx_rescue_animal_gallery_link_container', 'rescue_animal_gallery_link', 'contentcontainer_id', false); + + if ($this->db->getSchema()->getTableSchema('rescue_animal', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_gallery_link_animal', + 'rescue_animal_gallery_link', + 'animal_id', + 'rescue_animal', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + if ($this->db->getSchema()->getTableSchema('gallery_gallery', true) !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_gallery_link_gallery', + 'rescue_animal_gallery_link', + 'gallery_id', + 'gallery_gallery', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + } + + public function safeDown() + { + $this->safeDropTable('rescue_animal_gallery_link'); + } +} diff --git a/migrations/m260403_130000_add_medical_stream_and_flags.php b/migrations/m260403_130000_add_medical_stream_and_flags.php new file mode 100644 index 0000000..f5d6142 --- /dev/null +++ b/migrations/m260403_130000_add_medical_stream_and_flags.php @@ -0,0 +1,94 @@ +db->getSchema()->getTableSchema('rescue_animal_medical_visit', true); + if ($medicalTable !== null) { + if (!isset($medicalTable->columns['post_to_space_feed'])) { + $this->addColumn('rescue_animal_medical_visit', 'post_to_space_feed', $this->boolean()->notNull()->defaultValue(0)); + } + + if (!isset($medicalTable->columns['post_to_animal_feed'])) { + $this->addColumn('rescue_animal_medical_visit', 'post_to_animal_feed', $this->boolean()->notNull()->defaultValue(1)); + } + + $this->update('rescue_animal_medical_visit', ['post_to_space_feed' => 0], ['post_to_space_feed' => null]); + $this->update('rescue_animal_medical_visit', ['post_to_animal_feed' => 1], ['post_to_animal_feed' => null]); + } + + if ($this->db->getSchema()->getTableSchema('rescue_animal_stream_entry', true) === null) { + $this->safeCreateTable('rescue_animal_stream_entry', [ + 'id' => $this->primaryKey(), + 'animal_id' => $this->integer()->notNull(), + 'entry_type' => $this->string(32)->notNull(), + 'medical_visit_id' => $this->integer()->null(), + 'progress_update_id' => $this->integer()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_animal_stream_entry_animal', 'rescue_animal_stream_entry', 'animal_id', false); + $this->safeCreateIndex('idx_rescue_animal_stream_entry_type', 'rescue_animal_stream_entry', 'entry_type', false); + $this->safeCreateIndex('idx_rescue_animal_stream_entry_medical', 'rescue_animal_stream_entry', 'medical_visit_id', true); + $this->safeCreateIndex('idx_rescue_animal_stream_entry_progress', 'rescue_animal_stream_entry', 'progress_update_id', true); + + $this->safeAddForeignKey( + 'fk_rescue_animal_stream_entry_animal', + 'rescue_animal_stream_entry', + 'animal_id', + 'rescue_animal', + 'id', + 'CASCADE', + 'CASCADE' + ); + + $medicalVisitTable = $this->db->getSchema()->getTableSchema('rescue_animal_medical_visit', true); + if ($medicalVisitTable !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_stream_entry_medical', + 'rescue_animal_stream_entry', + 'medical_visit_id', + 'rescue_animal_medical_visit', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + $progressTable = $this->db->getSchema()->getTableSchema('rescue_animal_progress_update', true); + if ($progressTable !== null) { + $this->safeAddForeignKey( + 'fk_rescue_animal_stream_entry_progress', + 'rescue_animal_stream_entry', + 'progress_update_id', + 'rescue_animal_progress_update', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + } + } + + public function safeDown() + { + if ($this->db->getSchema()->getTableSchema('rescue_animal_stream_entry', true) !== null) { + $this->safeDropTable('rescue_animal_stream_entry'); + } + + $medicalTable = $this->db->getSchema()->getTableSchema('rescue_animal_medical_visit', true); + if ($medicalTable !== null) { + if (isset($medicalTable->columns['post_to_space_feed'])) { + $this->dropColumn('rescue_animal_medical_visit', 'post_to_space_feed'); + } + + if (isset($medicalTable->columns['post_to_animal_feed'])) { + $this->dropColumn('rescue_animal_medical_visit', 'post_to_animal_feed'); + } + } + } +} diff --git a/models/Animal.php b/models/Animal.php new file mode 100644 index 0000000..b839f74 --- /dev/null +++ b/models/Animal.php @@ -0,0 +1,213 @@ + 190], + [['species', 'breed', 'location_name'], 'string', 'max' => 120], + [['sex', 'status'], 'string', 'max' => 32], + [['city'], 'string', 'max' => 120], + [['state'], 'string', 'max' => 2], + [['zip'], 'string', 'max' => 10], + [['animal_uid'], 'unique'], + [['status'], 'in', 'range' => array_keys(self::statusOptions())], + ]; + } + + public function beforeValidate() + { + if (!parent::beforeValidate()) { + return false; + } + + if ($this->isNewRecord && empty($this->animal_uid)) { + $this->animal_uid = $this->generateAnimalUid(); + } + + if (empty($this->status)) { + $this->status = self::STATUS_INTAKE; + } + + return true; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public static function statusOptions(): array + { + return [ + self::STATUS_INTAKE => Yii::t('AnimalManagementModule.base', 'Intake'), + self::STATUS_ACTIVE => Yii::t('AnimalManagementModule.base', 'Active'), + self::STATUS_TRANSFER_PENDING => Yii::t('AnimalManagementModule.base', 'Transfer Pending'), + self::STATUS_TRANSFERRED => Yii::t('AnimalManagementModule.base', 'Transferred'), + self::STATUS_ADOPTED => Yii::t('AnimalManagementModule.base', 'Adopted'), + ]; + } + + public function getDisplayName(): string + { + $name = trim((string)$this->name); + return $name !== '' ? $name : (string)$this->animal_uid; + } + + public function getTransfers() + { + return $this->hasMany(AnimalTransfer::class, ['animal_id' => 'id']); + } + + public function getMedicalVisits() + { + return $this->hasMany(AnimalMedicalVisit::class, ['animal_id' => 'id']); + } + + public function getProgressUpdates() + { + return $this->hasMany(AnimalProgressUpdate::class, ['animal_id' => 'id']); + } + + public function getTransferEvents() + { + return $this->hasMany(AnimalTransferEvent::class, ['animal_id' => 'id']); + } + + public function getFieldValues() + { + return $this->hasMany(AnimalFieldValue::class, ['animal_id' => 'id']); + } + + public function getGalleryItems() + { + return $this->hasMany(AnimalGalleryItem::class, ['animal_id' => 'id']); + } + + public function getCustomFieldDisplayValues(bool $includeRestricted = false): array + { + $values = []; + + foreach ($this->getFieldValues()->with('fieldDefinition')->all() as $fieldValue) { + if (!$fieldValue instanceof AnimalFieldValue) { + continue; + } + + $definition = $fieldValue->fieldDefinition; + if ($definition === null || (string)$definition->module_id !== 'animal_management') { + continue; + } + + if ((int)$definition->is_active !== 1 || (int)$definition->is_core === 1) { + continue; + } + + if ((string)$definition->group_key !== 'animal_profile') { + continue; + } + + if (in_array((string)$definition->field_key, [ + 'cover_image_url', + 'profile_image_url', + 'photo_url', + 'image_url', + 'profile_image', + 'photo', + 'tile_display_fields', + 'hero_display_fields', + ], true)) { + continue; + } + + $visibility = (string)$definition->visibility; + if (!$includeRestricted && $visibility !== 'public') { + continue; + } + + $raw = trim((string)$fieldValue->value_text); + if ($raw === '') { + continue; + } + + $display = $raw; + $inputType = (string)$definition->input_type; + + if ($inputType === 'boolean') { + $display = in_array(strtolower($raw), ['1', 'true', 'yes', 'on'], true) + ? Yii::t('AnimalManagementModule.base', 'Yes') + : Yii::t('AnimalManagementModule.base', 'No'); + } elseif ($inputType === 'select') { + $display = $this->mapSelectDisplayValue((string)$definition->options, $raw); + } + + $values[] = [ + 'field_key' => (string)$definition->field_key, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : (string)$definition->field_key, + 'value' => $display, + 'visibility' => $visibility, + ]; + } + + return $values; + } + + private function mapSelectDisplayValue(string $options, string $raw): string + { + $options = trim($options); + if ($options === '') { + return $raw; + } + + $decoded = Json::decode($options, true); + if (!is_array($decoded)) { + return $raw; + } + + if (array_values($decoded) === $decoded) { + return in_array($raw, $decoded, true) ? $raw : $raw; + } + + return isset($decoded[$raw]) ? (string)$decoded[$raw] : $raw; + } + + private function generateAnimalUid(): string + { + do { + $candidate = 'ANI-' . date('Ymd') . '-' . strtoupper(substr(Yii::$app->security->generateRandomString(8), 0, 6)); + $exists = static::find()->where(['animal_uid' => $candidate])->exists(); + } while ($exists); + + return $candidate; + } +} diff --git a/models/AnimalFieldValue.php b/models/AnimalFieldValue.php new file mode 100644 index 0000000..2f0eb3d --- /dev/null +++ b/models/AnimalFieldValue.php @@ -0,0 +1,49 @@ + ['animal_id', 'field_definition_id']], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getAnimal() + { + return $this->hasOne(Animal::class, ['id' => 'animal_id']); + } + + public function getFieldDefinition() + { + return $this->hasOne(RescueFieldDefinition::class, ['id' => 'field_definition_id']); + } +} diff --git a/models/AnimalGalleryItem.php b/models/AnimalGalleryItem.php new file mode 100644 index 0000000..9187bab --- /dev/null +++ b/models/AnimalGalleryItem.php @@ -0,0 +1,75 @@ + 32], + [['file_path'], 'string', 'max' => 500], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + if (empty($this->source_type)) { + $this->source_type = 'upload'; + } + + $this->updated_at = $now; + return true; + } + + public function getAnimal() + { + return $this->hasOne(Animal::class, ['id' => 'animal_id']); + } + + public function getFile() + { + return $this->hasOne(File::class, ['id' => 'file_id']); + } + + public function getImageUrl(): string + { + $path = trim((string)$this->file_path); + if ($path !== '') { + if (preg_match('/^https?:\/\//i', $path) || substr($path, 0, 1) === '/') { + return $path; + } + + return '/' . ltrim($path, '/'); + } + + if ($this->file_id) { + $file = $this->file; + if ($file instanceof File) { + return (string)$file->getUrl([], false); + } + } + + return ''; + } +} diff --git a/models/AnimalGalleryLink.php b/models/AnimalGalleryLink.php new file mode 100644 index 0000000..a241313 --- /dev/null +++ b/models/AnimalGalleryLink.php @@ -0,0 +1,43 @@ +created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getAnimal() + { + return $this->hasOne(Animal::class, ['id' => 'animal_id']); + } +} diff --git a/models/AnimalMedicalVisit.php b/models/AnimalMedicalVisit.php new file mode 100644 index 0000000..bf25f2a --- /dev/null +++ b/models/AnimalMedicalVisit.php @@ -0,0 +1,94 @@ + 190], + ]; + + $table = Yii::$app->db->schema->getTableSchema(static::tableName(), true); + if ($table !== null && isset($table->columns['post_to_space_feed']) && isset($table->columns['post_to_animal_feed'])) { + $rules[] = [['post_to_space_feed', 'post_to_animal_feed'], 'integer']; + } + + return $rules; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getFieldValues() + { + return $this->hasMany(AnimalMedicalVisitFieldValue::class, ['medical_visit_id' => 'id']); + } + + public function getCustomFieldDisplayValues(bool $includeRestricted = false): array + { + $values = []; + + foreach ($this->getFieldValues()->with('fieldDefinition')->all() as $fieldValue) { + $definition = $fieldValue->fieldDefinition; + if ($definition === null || (string)$definition->module_id !== 'animal_management') { + continue; + } + + if ((int)$definition->is_active !== 1 || (string)$definition->group_key !== 'animal_medical_visit') { + continue; + } + + $visibility = (string)$definition->visibility; + if (!$includeRestricted && $visibility !== 'public') { + continue; + } + + $raw = trim((string)$fieldValue->value_text); + if ($raw === '') { + continue; + } + + $display = $raw; + $inputType = (string)$definition->input_type; + if ($inputType === 'boolean') { + $display = in_array(strtolower($raw), ['1', 'true', 'yes', 'on'], true) + ? Yii::t('AnimalManagementModule.base', 'Yes') + : Yii::t('AnimalManagementModule.base', 'No'); + } + + $values[] = [ + 'field_key' => (string)$definition->field_key, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : (string)$definition->field_key, + 'value' => $display, + ]; + } + + return $values; + } +} diff --git a/models/AnimalMedicalVisitFieldValue.php b/models/AnimalMedicalVisitFieldValue.php new file mode 100644 index 0000000..7de3efe --- /dev/null +++ b/models/AnimalMedicalVisitFieldValue.php @@ -0,0 +1,49 @@ + ['medical_visit_id', 'field_definition_id']], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getMedicalVisit() + { + return $this->hasOne(AnimalMedicalVisit::class, ['id' => 'medical_visit_id']); + } + + public function getFieldDefinition() + { + return $this->hasOne(RescueFieldDefinition::class, ['id' => 'field_definition_id']); + } +} diff --git a/models/AnimalProgressUpdate.php b/models/AnimalProgressUpdate.php new file mode 100644 index 0000000..43bcab7 --- /dev/null +++ b/models/AnimalProgressUpdate.php @@ -0,0 +1,87 @@ + 32], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getFieldValues() + { + return $this->hasMany(AnimalProgressUpdateFieldValue::class, ['progress_update_id' => 'id']); + } + + public function getCustomFieldDisplayValues(bool $includeRestricted = false): array + { + $values = []; + + foreach ($this->getFieldValues()->with('fieldDefinition')->all() as $fieldValue) { + $definition = $fieldValue->fieldDefinition; + if ($definition === null || (string)$definition->module_id !== 'animal_management') { + continue; + } + + if ((int)$definition->is_active !== 1 || (string)$definition->group_key !== 'animal_progress_update') { + continue; + } + + $visibility = (string)$definition->visibility; + if (!$includeRestricted && $visibility !== 'public') { + continue; + } + + $raw = trim((string)$fieldValue->value_text); + if ($raw === '') { + continue; + } + + $display = $raw; + $inputType = (string)$definition->input_type; + if ($inputType === 'boolean') { + $display = in_array(strtolower($raw), ['1', 'true', 'yes', 'on'], true) + ? Yii::t('AnimalManagementModule.base', 'Yes') + : Yii::t('AnimalManagementModule.base', 'No'); + } + + $values[] = [ + 'field_key' => (string)$definition->field_key, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : (string)$definition->field_key, + 'value' => $display, + ]; + } + + return $values; + } +} diff --git a/models/AnimalProgressUpdateFieldValue.php b/models/AnimalProgressUpdateFieldValue.php new file mode 100644 index 0000000..b1eae7b --- /dev/null +++ b/models/AnimalProgressUpdateFieldValue.php @@ -0,0 +1,49 @@ + ['progress_update_id', 'field_definition_id']], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function getProgressUpdate() + { + return $this->hasOne(AnimalProgressUpdate::class, ['id' => 'progress_update_id']); + } + + public function getFieldDefinition() + { + return $this->hasOne(RescueFieldDefinition::class, ['id' => 'field_definition_id']); + } +} diff --git a/models/AnimalStreamEntry.php b/models/AnimalStreamEntry.php new file mode 100644 index 0000000..d4c4e83 --- /dev/null +++ b/models/AnimalStreamEntry.php @@ -0,0 +1,101 @@ + 32], + [['entry_type'], 'in', 'range' => [self::TYPE_MEDICAL, self::TYPE_PROGRESS]], + [['medical_visit_id', 'progress_update_id'], 'validateLinkedRecord'], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public function validateLinkedRecord(string $attribute, $params): void + { + if ((string)$this->entry_type === self::TYPE_MEDICAL) { + if ((int)$this->medical_visit_id <= 0) { + $this->addError('medical_visit_id', Yii::t('AnimalManagementModule.base', 'Medical visit reference is required.')); + } + return; + } + + if ((string)$this->entry_type === self::TYPE_PROGRESS) { + if ((int)$this->progress_update_id <= 0) { + $this->addError('progress_update_id', Yii::t('AnimalManagementModule.base', 'Progress update reference is required.')); + } + return; + } + } + + public function getAnimal() + { + return $this->hasOne(Animal::class, ['id' => 'animal_id']); + } + + public function getMedicalVisit() + { + return $this->hasOne(AnimalMedicalVisit::class, ['id' => 'medical_visit_id']); + } + + public function getProgressUpdate() + { + return $this->hasOne(AnimalProgressUpdate::class, ['id' => 'progress_update_id']); + } + + public function getIcon() + { + return 'fa-paw'; + } + + public function getContentName() + { + return Yii::t('AnimalManagementModule.base', 'Animal stream update'); + } + + public function getContentDescription() + { + $animal = $this->animal; + $animalName = $animal instanceof Animal ? $animal->getDisplayName() : Yii::t('AnimalManagementModule.base', 'Animal'); + + if ((string)$this->entry_type === self::TYPE_MEDICAL) { + return Yii::t('AnimalManagementModule.base', 'Medical visit for {animal}', ['{animal}' => $animalName]); + } + + return Yii::t('AnimalManagementModule.base', 'Progress update for {animal}', ['{animal}' => $animalName]); + } +} diff --git a/models/AnimalTransfer.php b/models/AnimalTransfer.php new file mode 100644 index 0000000..99a62cb --- /dev/null +++ b/models/AnimalTransfer.php @@ -0,0 +1,158 @@ + 32], + [['status'], 'in', 'range' => array_keys(self::statusOptions())], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + + $this->updated_at = $now; + return true; + } + + public static function statusOptions(): array + { + return [ + self::STATUS_REQUESTED => Yii::t('AnimalManagementModule.base', 'Requested'), + self::STATUS_ACCEPTED => Yii::t('AnimalManagementModule.base', 'Accepted'), + self::STATUS_DECLINED => Yii::t('AnimalManagementModule.base', 'Declined'), + self::STATUS_COMPLETED => Yii::t('AnimalManagementModule.base', 'Completed'), + self::STATUS_CANCELLED => Yii::t('AnimalManagementModule.base', 'Cancelled'), + ]; + } + + public function getAnimal() + { + return $this->hasOne(Animal::class, ['id' => 'animal_id']); + } + + public function getFromSpace(): ?Space + { + return Space::findOne(['contentcontainer_id' => $this->from_contentcontainer_id]); + } + + public function getToSpace(): ?Space + { + return Space::findOne(['contentcontainer_id' => $this->to_contentcontainer_id]); + } + + public function getAuditEvents() + { + return $this->hasMany(AnimalTransferEvent::class, ['transfer_id' => 'id']); + } + + public function markAccepted(): bool + { + $fromStatus = $this->status; + $this->status = self::STATUS_ACCEPTED; + $this->responded_at = date('Y-m-d H:i:s'); + if (!$this->save(false, ['status', 'responded_at', 'updated_at'])) { + return false; + } + + AnimalTransferEvent::log( + $this, + AnimalTransferEvent::EVENT_ACCEPTED, + $fromStatus, + $this->status, + Yii::t('AnimalManagementModule.base', 'Transfer request accepted.') + ); + + return true; + } + + public function markDeclined(): bool + { + $fromStatus = $this->status; + $this->status = self::STATUS_DECLINED; + $this->responded_at = date('Y-m-d H:i:s'); + if (!$this->save(false, ['status', 'responded_at', 'updated_at'])) { + return false; + } + + AnimalTransferEvent::log( + $this, + AnimalTransferEvent::EVENT_DECLINED, + $fromStatus, + $this->status, + Yii::t('AnimalManagementModule.base', 'Transfer request declined.') + ); + + return true; + } + + public function markCompleted(): bool + { + $fromStatus = $this->status; + $this->status = self::STATUS_COMPLETED; + $this->completed_at = date('Y-m-d H:i:s'); + if (!$this->save(false, ['status', 'completed_at', 'updated_at'])) { + return false; + } + + AnimalTransferEvent::log( + $this, + AnimalTransferEvent::EVENT_COMPLETED, + $fromStatus, + $this->status, + Yii::t('AnimalManagementModule.base', 'Transfer completed.') + ); + + return true; + } + + public function markCancelled(string $message = ''): bool + { + $fromStatus = $this->status; + $this->status = self::STATUS_CANCELLED; + + if (!$this->save(false, ['status', 'updated_at'])) { + return false; + } + + AnimalTransferEvent::log( + $this, + AnimalTransferEvent::EVENT_CANCELLED, + $fromStatus, + $this->status, + $message !== '' ? $message : Yii::t('AnimalManagementModule.base', 'Transfer cancelled.') + ); + + return true; + } +} diff --git a/models/AnimalTransferEvent.php b/models/AnimalTransferEvent.php new file mode 100644 index 0000000..494e77e --- /dev/null +++ b/models/AnimalTransferEvent.php @@ -0,0 +1,82 @@ + 32], + [['event_type'], 'in', 'range' => [ + self::EVENT_REQUESTED, + self::EVENT_ACCEPTED, + self::EVENT_DECLINED, + self::EVENT_COMPLETED, + self::EVENT_CANCELLED, + ]], + ]; + } + + public function beforeSave($insert) + { + if (!$insert) { + return false; + } + + if (!parent::beforeSave($insert)) { + return false; + } + + if (empty($this->created_at)) { + $this->created_at = date('Y-m-d H:i:s'); + } + + return true; + } + + public static function log( + AnimalTransfer $transfer, + string $eventType, + ?string $fromStatus, + ?string $toStatus, + string $message = '', + array $metadata = [] + ): bool { + $event = new self(); + $event->transfer_id = (int)$transfer->id; + $event->animal_id = (int)$transfer->animal_id; + $event->event_type = $eventType; + $event->from_status = $fromStatus; + $event->to_status = $toStatus; + $event->message = $message; + $event->metadata_json = empty($metadata) ? null : Json::encode($metadata); + $event->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + + return $event->save(false); + } + + public function getCreatedByUser() + { + return $this->hasOne(User::class, ['id' => 'created_by']); + } +} diff --git a/models/forms/AnimalForm.php b/models/forms/AnimalForm.php new file mode 100644 index 0000000..08cf198 --- /dev/null +++ b/models/forms/AnimalForm.php @@ -0,0 +1,1024 @@ +bootstrapFieldConfig(); + } + + public function rules() + { + return [ + [['species', 'breed', 'sex', 'location_name', 'city', 'state', 'zip'], 'string', 'max' => 120], + [['name'], 'string', 'max' => 190], + [['status'], 'in', 'range' => array_keys(Animal::statusOptions())], + [['public_summary', 'medical_notes'], 'string'], + [['in_possession'], 'boolean'], + [['state'], 'string', 'max' => 2], + [['zip'], 'string', 'max' => 10], + [['removeCoverImage', 'removeProfileImage'], 'boolean'], + [['coverImageGalleryPath', 'profileImageGalleryPath'], 'string', 'max' => 500], + [['coverImageGalleryPath', 'profileImageGalleryPath'], 'validateGalleryImageSelection'], + [['tileDisplayFields', 'heroDisplayFields'], 'default', 'value' => []], + [['tileDisplayFields', 'heroDisplayFields'], 'each', 'rule' => ['in', 'range' => array_keys(DisplaySettingsForm::fieldOptions())]], + [['coverImageFile', 'profileImageFile'], 'file', + 'skipOnEmpty' => true, + 'extensions' => UploadStandards::imageExtensions(), + 'checkExtensionByMimeType' => true, + 'mimeTypes' => UploadStandards::imageMimeTypes(), + 'maxSize' => UploadStandards::IMAGE_MAX_BYTES, + ], + [['customFields'], 'safe'], + [['name', 'species', 'breed', 'sex', 'status', 'location_name', 'city', 'state', 'zip', 'public_summary', 'medical_notes'], 'validateConfiguredRequiredFields'], + [['customFields'], 'validateCustomFieldValues'], + ]; + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + try { + $animal = $this->animal ?: new Animal(); + $animal->contentcontainer_id = $this->contentContainer->contentcontainer_id; + $animal->name = trim($this->name); + $animal->species = trim($this->species); + $animal->breed = trim($this->breed); + $animal->sex = trim($this->sex); + $animal->status = $this->status; + $animal->in_possession = $this->in_possession ? 1 : 0; + $animal->public_summary = trim($this->public_summary); + $animal->medical_notes = trim($this->medical_notes); + + if ($this->in_possession) { + $animal->location_name = $this->getDefaultLocationName(); + $animal->city = ''; + $animal->state = ''; + $animal->zip = ''; + } else { + $animal->location_name = trim($this->location_name); + $animal->city = trim($this->city); + $animal->state = strtoupper(trim($this->state)); + $animal->zip = trim($this->zip); + } + + if (!$animal->save()) { + $this->addErrors($animal->getErrors()); + $transaction->rollBack(); + return false; + } + + if (!$this->saveCustomFieldValues($animal)) { + $transaction->rollBack(); + return false; + } + + if (!$this->saveImageFields($animal)) { + $transaction->rollBack(); + return false; + } + + if (!$this->saveDisplayFieldOverrides($animal)) { + $transaction->rollBack(); + return false; + } + + $transaction->commit(); + $this->animal = $animal; + return true; + } catch (\Throwable $e) { + $transaction->rollBack(); + Yii::error($e, 'animal_management.animal_form_save'); + + $message = (string)$e->getMessage(); + if (stripos($message, 'Permission denied') !== false) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save uploaded image due to file permission restrictions. Please contact your administrator.')); + } else { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Unexpected error while saving animal profile.')); + } + return false; + } + } + + public function getAnimal(): ?Animal + { + return $this->animal; + } + + public function setAnimal(Animal $animal): void + { + $this->animal = $animal; + $this->name = (string)$animal->name; + $this->species = (string)$animal->species; + $this->breed = (string)$animal->breed; + $this->sex = (string)$animal->sex; + $this->status = (string)$animal->status; + $this->in_possession = ((int)$animal->in_possession) === 1; + $this->location_name = (string)$animal->location_name; + $this->city = (string)$animal->city; + $this->state = (string)$animal->state; + $this->zip = (string)$animal->zip; + $this->public_summary = (string)$animal->public_summary; + $this->medical_notes = (string)$animal->medical_notes; + + $this->loadCustomFieldValues($animal); + $this->existingCoverImagePath = $this->getImageFieldValue($animal, 'cover_image_url'); + $this->existingProfileImagePath = $this->getImageFieldValue($animal, 'profile_image_url'); + $this->coverImageGalleryPath = (string)($this->existingCoverImagePath ?? ''); + $this->profileImageGalleryPath = (string)($this->existingProfileImagePath ?? ''); + $this->tileDisplayFields = $this->readDisplayFieldOverride($animal, 'tile_display_fields'); + $this->heroDisplayFields = $this->readDisplayFieldOverride($animal, 'hero_display_fields'); + } + + public function getStatusOptions(): array + { + return Animal::statusOptions(); + } + + public function isFieldActive(string $fieldKey): bool + { + if (!isset($this->fieldConfig[$fieldKey])) { + return true; + } + + return (int)$this->fieldConfig[$fieldKey]['is_active'] === 1; + } + + public function isFieldRequired(string $fieldKey): bool + { + if (!isset($this->fieldConfig[$fieldKey])) { + return false; + } + + return (int)$this->fieldConfig[$fieldKey]['required'] === 1; + } + + public function getFieldLabel(string $fieldKey): string + { + if (!isset($this->fieldConfig[$fieldKey])) { + return $this->getAttributeLabel($fieldKey); + } + + $label = trim((string)$this->fieldConfig[$fieldKey]['label']); + return $label !== '' ? $label : $this->getAttributeLabel($fieldKey); + } + + public function getCustomFieldDefinitions(): array + { + return $this->customFieldDefinitions; + } + + public function getCustomFieldSelectOptions(string $fieldKey): array + { + if (!isset($this->customFieldDefinitions[$fieldKey])) { + return []; + } + + return $this->parseSelectOptions((string)$this->customFieldDefinitions[$fieldKey]['options']); + } + + public function getExistingCoverImagePath(): ?string + { + return $this->existingCoverImagePath; + } + + public function getExistingProfileImagePath(): ?string + { + return $this->existingProfileImagePath; + } + + public function getGalleryImageOptions(): array + { + if (!$this->animal instanceof Animal || Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + return []; + } + + $items = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$this->animal->id]) + ->orderBy(['id' => SORT_DESC]) + ->all(); + + $options = []; + foreach ($items as $item) { + $url = trim((string)$item->getImageUrl()); + if ($url === '' || isset($options[$url]) || !$this->isResolvableImageUrl($url)) { + continue; + } + + $options[$url] = '#' . (int)$item->id . ' - ' . basename((string)parse_url($url, PHP_URL_PATH)); + } + + return $options; + } + + public function validateConfiguredRequiredFields(string $attribute): void + { + if (!$this->isFieldActive($attribute) || !$this->isFieldRequired($attribute)) { + return; + } + + if ($this->in_possession && in_array($attribute, ['location_name', 'city', 'state', 'zip'], true)) { + return; + } + + $value = $this->{$attribute}; + if (is_string($value)) { + $value = trim($value); + } + + if ($value === '' || $value === null) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', [ + '{field}' => $this->getFieldLabel($attribute), + ])); + } + } + + public function validateCustomFieldValues(string $attribute): void + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $value = $this->customFields[$fieldKey] ?? null; + $label = (string)$definition['label']; + $inputType = (string)$definition['input_type']; + $required = ((int)$definition['required']) === 1; + + if ($required && $this->isCustomValueEmpty($value, $inputType)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', ['{field}' => $label])); + continue; + } + + if ($this->isCustomValueEmpty($value, $inputType)) { + continue; + } + + $normalized = is_array($value) ? '' : trim((string)$value); + + if ($inputType === 'number' && !is_numeric($normalized)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid number.', ['{field}' => $label])); + continue; + } + + if (($inputType === 'date' || $inputType === 'datetime') && strtotime($normalized) === false) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid date/time.', ['{field}' => $label])); + continue; + } + + if ($inputType === 'select') { + $options = $this->getCustomFieldSelectOptions($fieldKey); + if (!empty($options) && !array_key_exists($normalized, $options)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Invalid selection for {field}.', ['{field}' => $label])); + } + } + } + } + + public function validateGalleryImageSelection(string $attribute): void + { + $selectedPath = trim((string)$this->{$attribute}); + if ($selectedPath === '') { + return; + } + + if (!$this->animal instanceof Animal) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Gallery image selection is available after creating the animal profile.')); + return; + } + + if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Gallery storage is unavailable.')); + return; + } + + $options = $this->getGalleryImageOptions(); + $exists = isset($options[$selectedPath]); + + if (!$exists) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Selected gallery image is not available for this animal.')); + } + } + + private function bootstrapFieldConfig(): void + { + $this->fieldConfig = $this->defaultFieldConfig(); + + if (!class_exists(RescueFieldDefinition::class)) { + return; + } + + if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $definitions = RescueFieldDefinition::find() + ->where(['module_id' => self::MODULE_ID]) + ->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_ASC]) + ->all(); + + foreach ($definitions as $definition) { + $fieldKey = (string)$definition->field_key; + if (isset($this->fieldConfig[$fieldKey])) { + $this->fieldConfig[$fieldKey] = [ + 'label' => (string)$definition->label, + 'required' => (int)$definition->required, + 'is_active' => (int)$definition->is_active, + ]; + continue; + } + + if ((int)$definition->is_active !== 1) { + continue; + } + + if ((string)$definition->group_key !== 'animal_profile') { + continue; + } + + if (in_array($fieldKey, ['cover_image_url', 'profile_image_url', 'photo_url', 'image_url', 'profile_image', 'photo', 'tile_display_fields', 'hero_display_fields'], true)) { + continue; + } + + $this->customFieldDefinitions[$fieldKey] = [ + 'id' => (int)$definition->id, + 'field_key' => $fieldKey, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : $fieldKey, + 'input_type' => (string)$definition->input_type, + 'required' => (int)$definition->required, + 'visibility' => (string)$definition->visibility, + 'options' => (string)$definition->options, + 'sort_order' => (int)$definition->sort_order, + ]; + } + } + + private function saveCustomFieldValues(Animal $animal): bool + { + if (empty($this->customFieldDefinitions)) { + return true; + } + + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $fieldDefinitionId = (int)$definition['id']; + if ($fieldDefinitionId === 0) { + continue; + } + + $inputType = (string)$definition['input_type']; + $rawValue = $this->customFields[$fieldKey] ?? null; + $valueText = $this->normalizeCustomValue($rawValue, $inputType); + + $record = AnimalFieldValue::findOne([ + 'animal_id' => (int)$animal->id, + 'field_definition_id' => $fieldDefinitionId, + ]); + + if ($this->isCustomValueEmpty($valueText, $inputType)) { + if ($record instanceof AnimalFieldValue) { + $record->delete(); + } + continue; + } + + if (!$record instanceof AnimalFieldValue) { + $record = new AnimalFieldValue(); + $record->animal_id = (int)$animal->id; + $record->field_definition_id = $fieldDefinitionId; + } + + $record->value_text = $valueText; + $record->value_json = null; + + if (!$record->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save custom field {field}.', [ + '{field}' => (string)$definition['label'], + ])); + foreach ($record->getFirstErrors() as $error) { + $this->addError('customFields', $error); + } + } + } + + if ($this->hasErrors('customFields')) { + return false; + } + + return true; + } + + private function loadCustomFieldValues(Animal $animal): void + { + if (empty($this->customFieldDefinitions)) { + return; + } + + $existingValues = AnimalFieldValue::find() + ->where(['animal_id' => (int)$animal->id]) + ->indexBy('field_definition_id') + ->all(); + + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $fieldDefinitionId = (int)$definition['id']; + if ($fieldDefinitionId === 0 || !isset($existingValues[$fieldDefinitionId])) { + continue; + } + + $value = (string)$existingValues[$fieldDefinitionId]->value_text; + if ((string)$definition['input_type'] === 'boolean') { + $this->customFields[$fieldKey] = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true) ? '1' : '0'; + } else { + $this->customFields[$fieldKey] = $value; + } + } + } + + private function normalizeCustomValue($value, string $inputType): ?string + { + if ($inputType === 'boolean') { + return !empty($value) ? '1' : '0'; + } + + if (is_array($value)) { + return Json::encode($value); + } + + return trim((string)$value); + } + + private function isCustomValueEmpty($value, string $inputType): bool + { + if ($inputType === 'boolean') { + return false; + } + + if (is_array($value)) { + return empty($value); + } + + return trim((string)$value) === ''; + } + + private function parseSelectOptions(string $options): array + { + $options = trim($options); + if ($options === '') { + return []; + } + + $decoded = null; + try { + $decoded = Json::decode($options, true); + } catch (\Throwable $e) { + $decoded = null; + } + + if (is_array($decoded)) { + $result = []; + if (array_values($decoded) === $decoded) { + foreach ($decoded as $item) { + $item = (string)$item; + if ($item === '') { + continue; + } + $result[$item] = $item; + } + + return $result; + } + + foreach ($decoded as $key => $value) { + $key = (string)$key; + if ($key === '') { + continue; + } + $result[$key] = (string)$value; + } + + return $result; + } + + $result = []; + foreach (preg_split('/[\r\n,]+/', $options) as $item) { + $item = trim((string)$item); + if ($item === '') { + continue; + } + + $result[$item] = $item; + } + + return $result; + } + + private function defaultFieldConfig(): array + { + return [ + 'name' => ['label' => 'Name', 'required' => 0, 'is_active' => 1], + 'species' => ['label' => 'Species', 'required' => 1, 'is_active' => 1], + 'breed' => ['label' => 'Breed', 'required' => 0, 'is_active' => 1], + 'sex' => ['label' => 'Sex', 'required' => 0, 'is_active' => 1], + 'status' => ['label' => 'Status', 'required' => 1, 'is_active' => 1], + 'in_possession' => ['label' => 'In possession', 'required' => 1, 'is_active' => 1], + 'location_name' => ['label' => 'Location name', 'required' => 0, 'is_active' => 1], + 'city' => ['label' => 'City', 'required' => 0, 'is_active' => 1], + 'state' => ['label' => 'State', 'required' => 0, 'is_active' => 1], + 'zip' => ['label' => 'ZIP', 'required' => 0, 'is_active' => 1], + 'public_summary' => ['label' => 'Public summary', 'required' => 0, 'is_active' => 1], + 'medical_notes' => ['label' => 'Medical notes', 'required' => 0, 'is_active' => 1], + ]; + } + + private function getDefaultLocationName(): string + { + if ($this->contentContainer instanceof Space) { + return trim((string)$this->contentContainer->name); + } + + return ''; + } + + private function saveImageFields(Animal $animal): bool + { + if ($this->removeCoverImage && !empty($this->existingCoverImagePath)) { + $this->deleteStoredFile($this->existingCoverImagePath, (int)$animal->id); + if (!$this->setImageFieldValue($animal, 'cover_image_url', null, 'Cover image URL', 230)) { + return false; + } + $this->existingCoverImagePath = null; + } + + if ($this->removeProfileImage && !empty($this->existingProfileImagePath)) { + $this->deleteStoredFile($this->existingProfileImagePath, (int)$animal->id); + if (!$this->setImageFieldValue($animal, 'profile_image_url', null, 'Profile image URL', 240)) { + return false; + } + $this->existingProfileImagePath = null; + } + + if ($this->coverImageFile instanceof UploadedFile) { + if (!empty($this->existingCoverImagePath)) { + $this->deleteStoredFile($this->existingCoverImagePath, (int)$animal->id); + } + + $stored = $this->storeImage($this->coverImageFile, 'cover', $animal); + if ($stored === null) { + $this->addError('coverImageFile', Yii::t('AnimalManagementModule.base', 'Could not upload cover image.')); + return false; + } + + if (!$this->setImageFieldValue($animal, 'cover_image_url', $stored, 'Cover image URL', 230)) { + return false; + } + + $this->existingCoverImagePath = $stored; + } + + $selectedCoverFromGallery = trim((string)$this->coverImageGalleryPath); + if (!$this->removeCoverImage && $selectedCoverFromGallery !== '' && !($this->coverImageFile instanceof UploadedFile)) { + if (!$this->setImageFieldValue($animal, 'cover_image_url', $selectedCoverFromGallery, 'Cover image URL', 230)) { + return false; + } + + $this->existingCoverImagePath = $selectedCoverFromGallery; + } + + if ($this->profileImageFile instanceof UploadedFile) { + if (!empty($this->existingProfileImagePath)) { + $this->deleteStoredFile($this->existingProfileImagePath, (int)$animal->id); + } + + $stored = $this->storeImage($this->profileImageFile, 'profile', $animal); + if ($stored === null) { + $this->addError('profileImageFile', Yii::t('AnimalManagementModule.base', 'Could not upload profile image.')); + return false; + } + + if (!$this->setImageFieldValue($animal, 'profile_image_url', $stored, 'Profile image URL', 240)) { + return false; + } + + $this->existingProfileImagePath = $stored; + } + + $selectedProfileFromGallery = trim((string)$this->profileImageGalleryPath); + if (!$this->removeProfileImage && $selectedProfileFromGallery !== '' && !($this->profileImageFile instanceof UploadedFile)) { + if (!$this->setImageFieldValue($animal, 'profile_image_url', $selectedProfileFromGallery, 'Profile image URL', 240)) { + return false; + } + + $this->existingProfileImagePath = $selectedProfileFromGallery; + } + + if (!$this->syncPrimaryImagesToGallery($animal)) { + return false; + } + + return true; + } + + private function readDisplayFieldOverride(Animal $animal, string $fieldKey): array + { + $raw = $this->getImageFieldValue($animal, $fieldKey); + if ($raw === null) { + return []; + } + + $decoded = json_decode((string)$raw, true); + if (!is_array($decoded)) { + $decoded = array_map('trim', explode(',', (string)$raw)); + } + + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $normalized = []; + foreach ($decoded as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + return $normalized; + } + + private function saveDisplayFieldOverrides(Animal $animal): bool + { + $tileFields = $this->normalizeDisplayFields($this->tileDisplayFields); + $heroFields = $this->normalizeDisplayFields($this->heroDisplayFields); + + if (!$this->setImageFieldValue($animal, 'tile_display_fields', empty($tileFields) ? null : json_encode($tileFields), 'Tile Display Fields', 250)) { + return false; + } + + if (!$this->setImageFieldValue($animal, 'hero_display_fields', empty($heroFields) ? null : json_encode($heroFields), 'Hero Display Fields', 251)) { + return false; + } + + return true; + } + + private function normalizeDisplayFields(array $fields): array + { + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $normalized = []; + foreach ($fields as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + return $normalized; + } + + private function storeImage(UploadedFile $file, string $prefix, Animal $animal): ?string + { + $random = Yii::$app->security->generateRandomString(8); + $extension = strtolower((string)$file->extension); + $fileName = $prefix . '-' . time() . '-' . $random . '.' . $extension; + + $candidateDirs = [ + '/uploads/animal-management/animals/' . (int)$animal->id, + '/uploads/animal-management-runtime/animals/' . (int)$animal->id, + '/uploads/animal-media/animals/' . (int)$animal->id, + ]; + + foreach ($candidateDirs as $relativeDir) { + $absoluteDir = Yii::getAlias('@webroot') . $relativeDir; + try { + FileHelper::createDirectory($absoluteDir, 0775, true); + } catch (\Throwable $e) { + Yii::warning($e->getMessage(), 'animal_management.image_upload_dir'); + continue; + } + + if (!is_dir($absoluteDir) || !is_writable($absoluteDir)) { + continue; + } + + $absolutePath = $absoluteDir . '/' . $fileName; + if ($file->saveAs($absolutePath)) { + return $relativeDir . '/' . $fileName; + } + } + + return null; + } + + private function deleteStoredFile(?string $relativePath, int $animalId): void + { + if (empty($relativePath)) { + return; + } + + if ($this->isGalleryReferencedPath($animalId, $relativePath)) { + return; + } + + $absolutePath = Yii::getAlias('@webroot') . $relativePath; + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + } + + private function getImageFieldValue(Animal $animal, string $fieldKey): ?string + { + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return null; + } + + $definition = RescueFieldDefinition::findOne([ + 'module_id' => self::MODULE_ID, + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + ]); + + if (!$definition instanceof RescueFieldDefinition) { + return null; + } + + $fieldValue = AnimalFieldValue::findOne([ + 'animal_id' => (int)$animal->id, + 'field_definition_id' => (int)$definition->id, + ]); + + if (!$fieldValue instanceof AnimalFieldValue) { + return null; + } + + $value = trim((string)$fieldValue->value_text); + return $value !== '' ? $value : null; + } + + private function setImageFieldValue(Animal $animal, string $fieldKey, ?string $path, string $label, int $sortOrder): bool + { + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return true; + } + + $definition = RescueFieldDefinition::findOne([ + 'module_id' => self::MODULE_ID, + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + ]); + + $isInternalDisplayOverride = in_array($fieldKey, ['tile_display_fields', 'hero_display_fields'], true); + + if (!$definition instanceof RescueFieldDefinition) { + $definition = new RescueFieldDefinition(); + $definition->module_id = self::MODULE_ID; + $definition->group_key = 'animal_profile'; + $definition->field_key = $fieldKey; + $definition->label = $label; + $definition->input_type = 'text'; + $definition->required = 0; + $definition->is_core = $isInternalDisplayOverride ? 1 : 0; + $definition->is_active = 1; + $definition->visibility = $isInternalDisplayOverride ? 'internal' : 'public'; + $definition->options = '{}'; + $definition->sort_order = $sortOrder; + $definition->created_at = date('Y-m-d H:i:s'); + $definition->updated_at = date('Y-m-d H:i:s'); + + if (!$definition->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not create image field definition.')); + return false; + } + } elseif ($isInternalDisplayOverride) { + $definition->label = $label; + $definition->input_type = 'text'; + $definition->required = 0; + $definition->is_core = 1; + $definition->is_active = 1; + $definition->visibility = 'internal'; + $definition->sort_order = $sortOrder; + $definition->updated_at = date('Y-m-d H:i:s'); + + if (!$definition->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not update display override field definition.')); + return false; + } + } + + $record = AnimalFieldValue::findOne([ + 'animal_id' => (int)$animal->id, + 'field_definition_id' => (int)$definition->id, + ]); + + $path = trim((string)$path); + if ($path === '') { + if ($record instanceof AnimalFieldValue) { + $record->delete(); + } + + return true; + } + + if (!$record instanceof AnimalFieldValue) { + $record = new AnimalFieldValue(); + $record->animal_id = (int)$animal->id; + $record->field_definition_id = (int)$definition->id; + } + + $record->value_text = $path; + $record->value_json = null; + + if (!$record->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save image path field value.')); + return false; + } + + return true; + } + + private function syncPrimaryImagesToGallery(Animal $animal): bool + { + if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + return true; + } + + $coverPath = $this->getImageFieldValue($animal, 'cover_image_url'); + $profilePath = $this->getImageFieldValue($animal, 'profile_image_url'); + + if (!$this->ensureGalleryItemForPath($animal, $coverPath, 'cover')) { + return false; + } + + if (!$this->ensureGalleryItemForPath($animal, $profilePath, 'profile')) { + return false; + } + + return true; + } + + private function ensureGalleryItemForPath(Animal $animal, ?string $imagePath, string $sourceType): bool + { + $imagePath = trim((string)$imagePath); + if ($imagePath === '') { + return true; + } + + if (!$this->isResolvableImageUrl($imagePath)) { + return true; + } + + $targetHash = $this->computeImageContentHash($imagePath); + + $exists = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id, 'file_path' => $imagePath]) + ->exists(); + + if (!$exists) { + $items = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all(); + foreach ($items as $item) { + $itemUrl = trim((string)$item->getImageUrl()); + if ($itemUrl === '') { + continue; + } + + if ($itemUrl === $imagePath) { + $exists = true; + break; + } + + if ($targetHash !== null) { + $itemHash = $this->computeImageContentHash($itemUrl); + if ($itemHash !== null && hash_equals($targetHash, $itemHash)) { + $exists = true; + break; + } + } + } + } + + if ($exists) { + return true; + } + + $item = new AnimalGalleryItem(); + $item->animal_id = (int)$animal->id; + $item->file_path = $imagePath; + $item->source_type = $sourceType; + $item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + + if (!$item->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not add profile image to gallery.')); + return false; + } + + return true; + } + + private function isGalleryReferencedPath(int $animalId, string $relativePath): bool + { + if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) { + return false; + } + + return AnimalGalleryItem::find() + ->where(['animal_id' => $animalId, 'file_path' => $relativePath]) + ->exists(); + } + + private function computeImageContentHash(string $pathOrUrl): ?string + { + $pathOrUrl = trim($pathOrUrl); + if ($pathOrUrl === '') { + return null; + } + + $absolutePath = $pathOrUrl; + if (substr($absolutePath, 0, 1) === '/') { + $absolutePath = Yii::getAlias('@webroot') . $absolutePath; + } + + if (!is_file($absolutePath) || !is_readable($absolutePath)) { + return null; + } + + $hash = @hash_file('sha1', $absolutePath); + return is_string($hash) && $hash !== '' ? $hash : null; + } + + private function isResolvableImageUrl(string $pathOrUrl): bool + { + $pathOrUrl = trim($pathOrUrl); + if ($pathOrUrl === '') { + return false; + } + + if (preg_match('/^https?:\/\//i', $pathOrUrl)) { + return true; + } + + if (substr($pathOrUrl, 0, 1) !== '/') { + return false; + } + + $absolutePath = Yii::getAlias('@webroot') . $pathOrUrl; + return is_file($absolutePath) && is_readable($absolutePath); + } +} diff --git a/models/forms/AnimalMedicalVisitForm.php b/models/forms/AnimalMedicalVisitForm.php new file mode 100644 index 0000000..25cc4c1 --- /dev/null +++ b/models/forms/AnimalMedicalVisitForm.php @@ -0,0 +1,346 @@ +bootstrapCustomFieldDefinitions(); + } + + public function rules() + { + return [ + [['visit_at', 'provider_name', 'notes', 'recommendations'], 'safe'], + [['provider_name'], 'string', 'max' => 190], + [['notes', 'recommendations'], 'string'], + [['post_to_space_feed', 'post_to_animal_feed'], 'boolean'], + [['customFields'], 'safe'], + [['customFields'], 'validateCustomFieldValues'], + ]; + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $record = $this->medicalVisit instanceof AnimalMedicalVisit + ? $this->medicalVisit + : new AnimalMedicalVisit(); + + $isNew = $record->getIsNewRecord(); + + if ($isNew) { + $record->animal_id = (int)$this->animal->id; + $record->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + } + + $record->visit_at = $this->normalizeDateTime($this->visit_at); + $record->provider_name = trim($this->provider_name); + $record->notes = trim($this->notes); + $record->recommendations = trim($this->recommendations); + if ($record->hasAttribute('post_to_space_feed') && $record->hasAttribute('post_to_animal_feed')) { + $record->post_to_space_feed = $this->post_to_space_feed ? 1 : 0; + $record->post_to_animal_feed = $this->post_to_animal_feed ? 1 : 0; + } + + if (!$record->save()) { + $this->addErrors($record->getErrors()); + return false; + } + + $this->medicalVisit = $record; + + if (!$this->saveCustomFieldValues($record)) { + return false; + } + + if ($this->post_to_space_feed) { + AnimalStreamPublisherService::publishMedicalVisit($this->animal, $record); + } + + return true; + } + + public function setMedicalVisit(AnimalMedicalVisit $medicalVisit): void + { + $this->medicalVisit = $medicalVisit; + $this->visit_at = (string)$medicalVisit->visit_at; + $this->provider_name = (string)$medicalVisit->provider_name; + $this->notes = (string)$medicalVisit->notes; + $this->recommendations = (string)$medicalVisit->recommendations; + if ($medicalVisit->hasAttribute('post_to_space_feed') && $medicalVisit->hasAttribute('post_to_animal_feed')) { + $this->post_to_space_feed = ((int)$medicalVisit->post_to_space_feed) === 1; + $this->post_to_animal_feed = ((int)$medicalVisit->post_to_animal_feed) === 1; + } else { + $this->post_to_space_feed = false; + $this->post_to_animal_feed = true; + } + + $this->loadCustomFieldValues($medicalVisit); + } + + public function getCustomFieldDefinitions(): array + { + return $this->customFieldDefinitions; + } + + public function getCustomFieldSelectOptions(string $fieldKey): array + { + if (!isset($this->customFieldDefinitions[$fieldKey])) { + return []; + } + + return $this->parseSelectOptions((string)$this->customFieldDefinitions[$fieldKey]['options']); + } + + public function validateCustomFieldValues(string $attribute): void + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $value = $this->customFields[$fieldKey] ?? null; + $label = (string)$definition['label']; + $inputType = (string)$definition['input_type']; + $required = ((int)$definition['required']) === 1; + + if ($required && $this->isCustomValueEmpty($value, $inputType)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', ['{field}' => $label])); + continue; + } + + if ($this->isCustomValueEmpty($value, $inputType)) { + continue; + } + + $normalized = is_array($value) ? '' : trim((string)$value); + + if ($inputType === 'number' && !is_numeric($normalized)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid number.', ['{field}' => $label])); + continue; + } + + if (($inputType === 'date' || $inputType === 'datetime') && strtotime($normalized) === false) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid date/time.', ['{field}' => $label])); + continue; + } + + if ($inputType === 'select') { + $options = $this->getCustomFieldSelectOptions($fieldKey); + if (!empty($options) && !array_key_exists($normalized, $options)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Invalid selection for {field}.', ['{field}' => $label])); + } + } + } + } + + private function normalizeDateTime(string $value): string + { + $value = trim($value); + if ($value === '') { + return date('Y-m-d H:i:s'); + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return date('Y-m-d H:i:s'); + } + + return date('Y-m-d H:i:s', $timestamp); + } + + private function bootstrapCustomFieldDefinitions(): void + { + if (!class_exists(RescueFieldDefinition::class)) { + return; + } + + if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $definitions = RescueFieldDefinition::find() + ->where(['module_id' => self::MODULE_ID, 'group_key' => 'animal_medical_visit', 'is_active' => 1]) + ->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_ASC]) + ->all(); + + foreach ($definitions as $definition) { + $fieldKey = (string)$definition->field_key; + $this->customFieldDefinitions[$fieldKey] = [ + 'id' => (int)$definition->id, + 'field_key' => $fieldKey, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : $fieldKey, + 'input_type' => (string)$definition->input_type, + 'required' => (int)$definition->required, + 'visibility' => (string)$definition->visibility, + 'options' => (string)$definition->options, + ]; + } + } + + private function saveCustomFieldValues(AnimalMedicalVisit $record): bool + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $fieldDefinitionId = (int)$definition['id']; + if ($fieldDefinitionId === 0) { + continue; + } + + $inputType = (string)$definition['input_type']; + $rawValue = $this->customFields[$fieldKey] ?? null; + $valueText = $this->normalizeCustomValue($rawValue, $inputType); + + $valueRecord = AnimalMedicalVisitFieldValue::findOne([ + 'medical_visit_id' => (int)$record->id, + 'field_definition_id' => $fieldDefinitionId, + ]); + + if ($this->isCustomValueEmpty($valueText, $inputType)) { + if ($valueRecord instanceof AnimalMedicalVisitFieldValue) { + $valueRecord->delete(); + } + continue; + } + + if (!$valueRecord instanceof AnimalMedicalVisitFieldValue) { + $valueRecord = new AnimalMedicalVisitFieldValue(); + $valueRecord->medical_visit_id = (int)$record->id; + $valueRecord->field_definition_id = $fieldDefinitionId; + } + + $valueRecord->value_text = $valueText; + $valueRecord->value_json = null; + + if (!$valueRecord->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save custom medical field {field}.', [ + '{field}' => (string)$definition['label'], + ])); + foreach ($valueRecord->getFirstErrors() as $error) { + $this->addError('customFields', $error); + } + } + } + + return !$this->hasErrors('customFields'); + } + + private function normalizeCustomValue($value, string $inputType): ?string + { + if ($inputType === 'boolean') { + return !empty($value) ? '1' : '0'; + } + + if (is_array($value)) { + return Json::encode($value); + } + + return trim((string)$value); + } + + private function isCustomValueEmpty($value, string $inputType): bool + { + if ($inputType === 'boolean') { + return false; + } + + if (is_array($value)) { + return empty($value); + } + + return trim((string)$value) === ''; + } + + private function parseSelectOptions(string $options): array + { + $options = trim($options); + if ($options === '') { + return []; + } + + $decoded = null; + try { + $decoded = Json::decode($options, true); + } catch (\Throwable $e) { + $decoded = null; + } + + if (is_array($decoded)) { + $result = []; + if (array_values($decoded) === $decoded) { + foreach ($decoded as $item) { + $item = (string)$item; + if ($item === '') { + continue; + } + $result[$item] = $item; + } + + return $result; + } + + foreach ($decoded as $key => $value) { + $key = (string)$key; + if ($key === '') { + continue; + } + $result[$key] = (string)$value; + } + + return $result; + } + + $result = []; + foreach (preg_split('/[\r\n,]+/', $options) as $item) { + $item = trim((string)$item); + if ($item === '') { + continue; + } + + $result[$item] = $item; + } + + return $result; + } + + private function loadCustomFieldValues(AnimalMedicalVisit $record): void + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $value = AnimalMedicalVisitFieldValue::findOne([ + 'medical_visit_id' => (int)$record->id, + 'field_definition_id' => (int)$definition['id'], + ]); + + if (!$value instanceof AnimalMedicalVisitFieldValue) { + continue; + } + + $this->customFields[$fieldKey] = (string)$value->value_text; + } + } +} diff --git a/models/forms/AnimalProgressUpdateForm.php b/models/forms/AnimalProgressUpdateForm.php new file mode 100644 index 0000000..02708d0 --- /dev/null +++ b/models/forms/AnimalProgressUpdateForm.php @@ -0,0 +1,348 @@ +bootstrapCustomFieldDefinitions(); + } + + public function rules() + { + return [ + [['update_at', 'weight', 'vitals', 'behavior_notes', 'meal_plan_changes', 'housing_changes', 'medical_concerns'], 'safe'], + [['vitals', 'behavior_notes', 'meal_plan_changes', 'housing_changes', 'medical_concerns'], 'string'], + [['weight'], 'string', 'max' => 32], + [['post_to_space_feed', 'post_to_animal_feed'], 'boolean'], + [['customFields'], 'safe'], + [['customFields'], 'validateCustomFieldValues'], + ]; + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $record = $this->progressUpdate instanceof AnimalProgressUpdate + ? $this->progressUpdate + : new AnimalProgressUpdate(); + + $isNew = $record->getIsNewRecord(); + + if ($isNew) { + $record->animal_id = (int)$this->animal->id; + $record->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + } + + $record->update_at = $this->normalizeDateTime($this->update_at); + $record->weight = trim($this->weight); + $record->vitals = trim($this->vitals); + $record->behavior_notes = trim($this->behavior_notes); + $record->meal_plan_changes = trim($this->meal_plan_changes); + $record->housing_changes = trim($this->housing_changes); + $record->medical_concerns = trim($this->medical_concerns); + $record->post_to_space_feed = $this->post_to_space_feed ? 1 : 0; + $record->post_to_animal_feed = $this->post_to_animal_feed ? 1 : 0; + + if (!$record->save()) { + $this->addErrors($record->getErrors()); + return false; + } + + $this->progressUpdate = $record; + + if (!$this->saveCustomFieldValues($record)) { + return false; + } + + if ($record->post_to_space_feed) { + AnimalStreamPublisherService::publishProgressUpdate($this->animal, $record); + } + + return true; + } + + public function setProgressUpdate(AnimalProgressUpdate $progressUpdate): void + { + $this->progressUpdate = $progressUpdate; + $this->update_at = (string)$progressUpdate->update_at; + $this->weight = (string)$progressUpdate->weight; + $this->vitals = (string)$progressUpdate->vitals; + $this->behavior_notes = (string)$progressUpdate->behavior_notes; + $this->meal_plan_changes = (string)$progressUpdate->meal_plan_changes; + $this->housing_changes = (string)$progressUpdate->housing_changes; + $this->medical_concerns = (string)$progressUpdate->medical_concerns; + $this->post_to_space_feed = ((int)$progressUpdate->post_to_space_feed) === 1; + $this->post_to_animal_feed = ((int)$progressUpdate->post_to_animal_feed) === 1; + + $this->loadCustomFieldValues($progressUpdate); + } + + public function getCustomFieldDefinitions(): array + { + return $this->customFieldDefinitions; + } + + public function getCustomFieldSelectOptions(string $fieldKey): array + { + if (!isset($this->customFieldDefinitions[$fieldKey])) { + return []; + } + + return $this->parseSelectOptions((string)$this->customFieldDefinitions[$fieldKey]['options']); + } + + public function validateCustomFieldValues(string $attribute): void + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $value = $this->customFields[$fieldKey] ?? null; + $label = (string)$definition['label']; + $inputType = (string)$definition['input_type']; + $required = ((int)$definition['required']) === 1; + + if ($required && $this->isCustomValueEmpty($value, $inputType)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', ['{field}' => $label])); + continue; + } + + if ($this->isCustomValueEmpty($value, $inputType)) { + continue; + } + + $normalized = is_array($value) ? '' : trim((string)$value); + + if ($inputType === 'number' && !is_numeric($normalized)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid number.', ['{field}' => $label])); + continue; + } + + if (($inputType === 'date' || $inputType === 'datetime') && strtotime($normalized) === false) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid date/time.', ['{field}' => $label])); + continue; + } + + if ($inputType === 'select') { + $options = $this->getCustomFieldSelectOptions($fieldKey); + if (!empty($options) && !array_key_exists($normalized, $options)) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Invalid selection for {field}.', ['{field}' => $label])); + } + } + } + } + + private function normalizeDateTime(string $value): string + { + $value = trim($value); + if ($value === '') { + return date('Y-m-d H:i:s'); + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return date('Y-m-d H:i:s'); + } + + return date('Y-m-d H:i:s', $timestamp); + } + + private function bootstrapCustomFieldDefinitions(): void + { + if (!class_exists(RescueFieldDefinition::class)) { + return; + } + + if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $definitions = RescueFieldDefinition::find() + ->where(['module_id' => self::MODULE_ID, 'group_key' => 'animal_progress_update', 'is_active' => 1]) + ->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_ASC]) + ->all(); + + foreach ($definitions as $definition) { + $fieldKey = (string)$definition->field_key; + $this->customFieldDefinitions[$fieldKey] = [ + 'id' => (int)$definition->id, + 'field_key' => $fieldKey, + 'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : $fieldKey, + 'input_type' => (string)$definition->input_type, + 'required' => (int)$definition->required, + 'visibility' => (string)$definition->visibility, + 'options' => (string)$definition->options, + ]; + } + } + + private function saveCustomFieldValues(AnimalProgressUpdate $record): bool + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $fieldDefinitionId = (int)$definition['id']; + if ($fieldDefinitionId === 0) { + continue; + } + + $inputType = (string)$definition['input_type']; + $rawValue = $this->customFields[$fieldKey] ?? null; + $valueText = $this->normalizeCustomValue($rawValue, $inputType); + + $valueRecord = AnimalProgressUpdateFieldValue::findOne([ + 'progress_update_id' => (int)$record->id, + 'field_definition_id' => $fieldDefinitionId, + ]); + + if ($this->isCustomValueEmpty($valueText, $inputType)) { + if ($valueRecord instanceof AnimalProgressUpdateFieldValue) { + $valueRecord->delete(); + } + continue; + } + + if (!$valueRecord instanceof AnimalProgressUpdateFieldValue) { + $valueRecord = new AnimalProgressUpdateFieldValue(); + $valueRecord->progress_update_id = (int)$record->id; + $valueRecord->field_definition_id = $fieldDefinitionId; + } + + $valueRecord->value_text = $valueText; + $valueRecord->value_json = null; + + if (!$valueRecord->save()) { + $this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save custom progress field {field}.', [ + '{field}' => (string)$definition['label'], + ])); + foreach ($valueRecord->getFirstErrors() as $error) { + $this->addError('customFields', $error); + } + } + } + + return !$this->hasErrors('customFields'); + } + + private function normalizeCustomValue($value, string $inputType): ?string + { + if ($inputType === 'boolean') { + return !empty($value) ? '1' : '0'; + } + + if (is_array($value)) { + return Json::encode($value); + } + + return trim((string)$value); + } + + private function isCustomValueEmpty($value, string $inputType): bool + { + if ($inputType === 'boolean') { + return false; + } + + if (is_array($value)) { + return empty($value); + } + + return trim((string)$value) === ''; + } + + private function parseSelectOptions(string $options): array + { + $options = trim($options); + if ($options === '') { + return []; + } + + $decoded = null; + try { + $decoded = Json::decode($options, true); + } catch (\Throwable $e) { + $decoded = null; + } + + if (is_array($decoded)) { + $result = []; + if (array_values($decoded) === $decoded) { + foreach ($decoded as $item) { + $item = (string)$item; + if ($item === '') { + continue; + } + $result[$item] = $item; + } + + return $result; + } + + foreach ($decoded as $key => $value) { + $key = (string)$key; + if ($key === '') { + continue; + } + $result[$key] = (string)$value; + } + + return $result; + } + + $result = []; + foreach (preg_split('/[\r\n,]+/', $options) as $item) { + $item = trim((string)$item); + if ($item === '') { + continue; + } + + $result[$item] = $item; + } + + return $result; + } + + private function loadCustomFieldValues(AnimalProgressUpdate $record): void + { + foreach ($this->customFieldDefinitions as $fieldKey => $definition) { + $value = AnimalProgressUpdateFieldValue::findOne([ + 'progress_update_id' => (int)$record->id, + 'field_definition_id' => (int)$definition['id'], + ]); + + if (!$value instanceof AnimalProgressUpdateFieldValue) { + continue; + } + + $this->customFields[$fieldKey] = (string)$value->value_text; + } + } +} diff --git a/models/forms/DisplaySettingsForm.php b/models/forms/DisplaySettingsForm.php new file mode 100644 index 0000000..0392ab6 --- /dev/null +++ b/models/forms/DisplaySettingsForm.php @@ -0,0 +1,155 @@ + 190], + [['tile_fields', 'detail_fields'], 'each', 'rule' => ['in', 'range' => array_keys(self::fieldOptions())]], + [['tile_fields', 'detail_fields'], 'default', 'value' => []], + ]; + } + + public function attributeLabels() + { + return [ + 'search_block_heading' => Yii::t('AnimalManagementModule.base', 'Search Block Heading'), + 'tile_fields' => Yii::t('AnimalManagementModule.base', 'Animal Tile Fields'), + 'detail_fields' => Yii::t('AnimalManagementModule.base', 'Animal Detail Hero Fields'), + ]; + } + + public function loadValues(): void + { + if ($this->contentContainer === null) { + return; + } + + $settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer); + + $heading = trim((string)$settings->get('searchBlockHeading', self::DEFAULT_SEARCH_BLOCK_HEADING)); + $this->search_block_heading = $heading !== '' ? $heading : self::DEFAULT_SEARCH_BLOCK_HEADING; + + $this->tile_fields = $this->normalizeFields( + $settings->get('tileFields', Json::encode(self::DEFAULT_TILE_FIELDS)), + self::DEFAULT_TILE_FIELDS + ); + + $this->detail_fields = $this->normalizeFields( + $settings->get('detailHeroFields', Json::encode(self::DEFAULT_DETAIL_FIELDS)), + self::DEFAULT_DETAIL_FIELDS + ); + } + + public function save(): bool + { + if ($this->contentContainer === null) { + $this->addError('search_block_heading', Yii::t('AnimalManagementModule.base', 'Missing content container.')); + return false; + } + + if (!$this->validate()) { + return false; + } + + $settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer); + + $heading = trim($this->search_block_heading); + $settings->set('searchBlockHeading', $heading !== '' ? $heading : self::DEFAULT_SEARCH_BLOCK_HEADING); + $settings->set('tileFields', Json::encode($this->normalizeFieldArray($this->tile_fields, self::DEFAULT_TILE_FIELDS))); + $settings->set('detailHeroFields', Json::encode($this->normalizeFieldArray($this->detail_fields, self::DEFAULT_DETAIL_FIELDS))); + + return true; + } + + public static function fieldOptions(): array + { + return [ + 'name' => Yii::t('AnimalManagementModule.base', 'Name'), + 'species' => Yii::t('AnimalManagementModule.base', 'Species'), + 'breed' => Yii::t('AnimalManagementModule.base', 'Breed'), + 'sex' => Yii::t('AnimalManagementModule.base', 'Sex'), + 'status' => Yii::t('AnimalManagementModule.base', 'Status'), + 'location_name' => Yii::t('AnimalManagementModule.base', 'Location'), + 'animal_uid' => Yii::t('AnimalManagementModule.base', 'ID'), + 'last_medical' => Yii::t('AnimalManagementModule.base', 'Last Medical Visit'), + 'public_summary' => Yii::t('AnimalManagementModule.base', 'Public Summary'), + ]; + } + + private function normalizeFields($raw, array $default): array + { + $decoded = []; + if (is_string($raw)) { + $raw = trim($raw); + if ($raw !== '') { + try { + $decoded = Json::decode($raw); + } catch (\Throwable $e) { + $decoded = []; + } + } + } elseif (is_array($raw)) { + $decoded = $raw; + } + + if (!is_array($decoded)) { + return $default; + } + + return $this->normalizeFieldArray($decoded, $default); + } + + private function normalizeFieldArray(array $fields, array $default): array + { + $normalized = []; + $options = self::fieldOptions(); + + foreach ($fields as $field) { + $field = trim((string)$field); + if ($field === '' || !isset($options[$field])) { + continue; + } + + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + if (empty($normalized)) { + return $default; + } + + return $normalized; + } +} diff --git a/models/forms/FieldDefinitionSettingsForm.php b/models/forms/FieldDefinitionSettingsForm.php new file mode 100644 index 0000000..0111271 --- /dev/null +++ b/models/forms/FieldDefinitionSettingsForm.php @@ -0,0 +1,335 @@ + 'Text', + 'textarea' => 'Textarea', + 'boolean' => 'Boolean', + 'select' => 'Select', + 'number' => 'Number', + 'date' => 'Date', + 'datetime' => 'Date/Time', + ]; + } + + public static function visibilityOptions(): array + { + return [ + 'public' => 'Public', + 'private' => 'Private', + 'internal' => 'Internal', + ]; + } + + public function loadRows(): void + { + if (!$this->canUseFieldDefinition()) { + return; + } + + $this->rows = []; + + $definitions = RescueFieldDefinition::find() + ->where(['module_id' => self::MODULE_ID]) + ->orderBy(['group_key' => SORT_ASC, 'sort_order' => SORT_ASC, 'id' => SORT_ASC]) + ->all(); + + foreach ($definitions as $definition) { + $this->rows[] = [ + 'id' => (int)$definition->id, + 'field_key' => (string)$definition->field_key, + 'label' => (string)$definition->label, + 'group_key' => (string)$definition->group_key, + 'input_type' => (string)$definition->input_type, + 'required' => (int)$definition->required, + 'is_core' => (int)$definition->is_core, + 'is_active' => (int)$definition->is_active, + 'visibility' => (string)$definition->visibility, + 'sort_order' => (int)$definition->sort_order, + ]; + } + } + + public function save(): bool + { + if (!$this->canUseFieldDefinition()) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Field definition storage is unavailable.')); + return false; + } + + $this->clearValidationErrors(); + + $byId = []; + foreach (RescueFieldDefinition::find()->where(['module_id' => self::MODULE_ID])->all() as $definition) { + $byId[(int)$definition->id] = $definition; + } + + $transaction = Yii::$app->db->beginTransaction(); + + try { + $now = date('Y-m-d H:i:s'); + + foreach ($this->rows as $index => $row) { + $id = (int)($row['id'] ?? 0); + if ($id === 0 || !isset($byId[$id])) { + continue; + } + + $definition = $byId[$id]; + $isCore = ((int)$definition->is_core) === 1; + $remove = !empty($row['remove']); + + if ($remove && !$isCore) { + if (!$definition->delete()) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Could not remove field {field}.', [ + '{field}' => (string)$definition->field_key, + ])); + } + continue; + } + + $label = trim((string)($row['label'] ?? '')); + $groupKey = trim((string)($row['group_key'] ?? $definition->group_key)); + $visibility = trim((string)($row['visibility'] ?? $definition->visibility)); + $sortOrder = (int)($row['sort_order'] ?? $definition->sort_order); + $required = !empty($row['required']) ? 1 : 0; + $isActive = !empty($row['is_active']) ? 1 : 0; + $inputType = trim((string)($row['input_type'] ?? $definition->input_type)); + $options = trim((string)$definition->options); + + if ($label === '') { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Field label cannot be empty for {field}.', [ + '{field}' => (string)$definition->field_key, + ])); + continue; + } + + if ($groupKey === '') { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Group key cannot be empty for {field}.', [ + '{field}' => (string)$definition->field_key, + ])); + continue; + } + + if (!array_key_exists($visibility, self::visibilityOptions())) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Invalid visibility for {field}.', [ + '{field}' => (string)$definition->field_key, + ])); + continue; + } + + if (!$isCore && !$this->validateSelectOptions($inputType, $options, (string)$definition->field_key)) { + continue; + } + + $definition->label = $label; + $definition->group_key = $groupKey; + $definition->visibility = $visibility; + $definition->sort_order = $sortOrder; + $definition->required = $isCore ? (int)$definition->required : $required; + $definition->is_active = $isCore ? (int)$definition->is_active : $isActive; + $definition->updated_at = $now; + + if (!$definition->save()) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Could not save field {field}.', [ + '{field}' => (string)$definition->field_key, + ])); + foreach ($definition->getFirstErrors() as $error) { + $this->addError('rows', $error); + } + } + + $this->rows[$index]['is_core'] = (int)$definition->is_core; + } + + if ($this->hasErrors()) { + $transaction->rollBack(); + return false; + } + + if (!$this->saveNewField($now)) { + $transaction->rollBack(); + return false; + } + + $transaction->commit(); + return true; + } catch (\Throwable $e) { + $transaction->rollBack(); + Yii::error($e, 'animal_management.field_definition_settings'); + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Unexpected error while saving field settings.')); + return false; + } + } + + public function canUseFieldDefinition(): bool + { + if (!class_exists(RescueFieldDefinition::class)) { + return false; + } + + return Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) !== null; + } + + private function saveNewField(string $now): bool + { + $newFieldKey = strtolower(trim($this->new_field_key)); + $newLabel = trim($this->new_label); + + if ($newFieldKey === '' && $newLabel === '') { + return true; + } + + if ($newFieldKey === '' || $newLabel === '') { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Both new field key and label are required.')); + return false; + } + + if (!preg_match('/^[a-z0-9_]+$/', $newFieldKey)) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'New field key must contain only lowercase letters, numbers, and underscores.')); + return false; + } + + if (!array_key_exists($this->new_input_type, self::inputTypeOptions())) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Invalid input type for new field.')); + return false; + } + + if (!array_key_exists($this->new_visibility, self::visibilityOptions())) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Invalid visibility for new field.')); + return false; + } + + $groupKey = trim($this->new_group_key); + if ($groupKey === '') { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'New field group key is required.')); + return false; + } + + $options = trim($this->new_options); + if (!$this->validateSelectOptions($this->new_input_type, $options, $newFieldKey)) { + return false; + } + + $exists = RescueFieldDefinition::find() + ->where(['module_id' => self::MODULE_ID, 'field_key' => $newFieldKey]) + ->exists(); + + if ($exists) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Field key {key} already exists.', ['{key}' => $newFieldKey])); + return false; + } + + $field = new RescueFieldDefinition(); + $field->module_id = self::MODULE_ID; + $field->group_key = $groupKey; + $field->field_key = $newFieldKey; + $field->label = $newLabel; + $field->input_type = $this->new_input_type; + $field->required = $this->new_required ? 1 : 0; + $field->is_core = 0; + $field->is_active = 1; + $field->visibility = $this->new_visibility; + $field->sort_order = (int)$this->new_sort_order; + $field->options = $options !== '' ? $options : '{}'; + $field->created_at = $now; + $field->updated_at = $now; + + if (!$field->save()) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Could not create new field.')); + foreach ($field->getFirstErrors() as $error) { + $this->addError('rows', $error); + } + return false; + } + + $this->new_field_key = ''; + $this->new_label = ''; + $this->new_input_type = 'text'; + $this->new_group_key = 'animal_profile'; + $this->new_visibility = 'public'; + $this->new_required = 0; + $this->new_sort_order = 500; + $this->new_options = ''; + + return true; + } + + private function clearValidationErrors(): void + { + foreach (array_keys($this->errors) as $attribute) { + $this->clearErrors($attribute); + } + } + + private function validateSelectOptions(string $inputType, string $options, string $fieldKey): bool + { + if ($inputType !== 'select') { + return true; + } + + if ($options === '' || $options === '{}') { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Select field {field} requires options JSON or comma/newline list.', [ + '{field}' => $fieldKey, + ])); + return false; + } + + $decoded = json_decode($options, true); + if (json_last_error() === JSON_ERROR_NONE) { + if (!is_array($decoded) || empty($decoded)) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Select field {field} options JSON must be a non-empty array/object.', [ + '{field}' => $fieldKey, + ])); + return false; + } + + return true; + } + + $parts = preg_split('/[\r\n,]+/', $options); + $parts = array_filter(array_map(static function ($item) { + return trim((string)$item); + }, $parts)); + + if (empty($parts)) { + $this->addError('rows', Yii::t('AnimalManagementModule.base', 'Select field {field} requires at least one option.', [ + '{field}' => $fieldKey, + ])); + return false; + } + + return true; + } +} diff --git a/models/forms/TransferRequestForm.php b/models/forms/TransferRequestForm.php new file mode 100644 index 0000000..16c0ebf --- /dev/null +++ b/models/forms/TransferRequestForm.php @@ -0,0 +1,165 @@ +hasErrors()) { + return; + } + + if ($this->to_space_id === (int)$this->sourceSpace->id) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'You must choose a different destination rescue.')); + return; + } + + $target = Space::findOne(['id' => $this->to_space_id, 'status' => Space::STATUS_ENABLED]); + if (!$target instanceof Space) { + $this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Selected destination rescue was not found.')); + } + } + + public function getTargetOptions(): array + { + return Space::find() + ->select(['name', 'id']) + ->where(['status' => Space::STATUS_ENABLED]) + ->andWhere(['!=', 'id', (int)$this->sourceSpace->id]) + ->orderBy(['name' => SORT_ASC]) + ->indexBy('id') + ->column(); + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $targetSpace = Space::findOne(['id' => $this->to_space_id, 'status' => Space::STATUS_ENABLED]); + if (!$targetSpace instanceof Space) { + $this->addError('to_space_id', Yii::t('AnimalManagementModule.base', 'Selected destination rescue was not found.')); + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + try { + $transfer = new AnimalTransfer(); + $transfer->animal_id = (int)$this->animal->id; + $transfer->from_contentcontainer_id = (int)$this->sourceSpace->contentcontainer_id; + $transfer->to_contentcontainer_id = (int)$targetSpace->contentcontainer_id; + $transfer->requested_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + $transfer->status = AnimalTransfer::STATUS_REQUESTED; + $transfer->request_message = trim($this->request_message); + $transfer->conditions_text = trim($this->conditions_text); + + if (!$transfer->save()) { + $this->addErrors($transfer->getErrors()); + $transaction->rollBack(); + return false; + } + + $this->animal->status = Animal::STATUS_TRANSFER_PENDING; + if (!$this->animal->save(false, ['status', 'updated_at'])) { + $transaction->rollBack(); + $this->addError('to_space_id', Yii::t('AnimalManagementModule.base', 'Could not update animal transfer status.')); + return false; + } + + $auditSaved = AnimalTransferEvent::log( + $transfer, + AnimalTransferEvent::EVENT_REQUESTED, + null, + AnimalTransfer::STATUS_REQUESTED, + Yii::t('AnimalManagementModule.base', 'Transfer requested from {from} to {to}.', [ + '{from}' => (string)$this->sourceSpace->name, + '{to}' => (string)$targetSpace->name, + ]), + [ + 'from_space_id' => (int)$this->sourceSpace->id, + 'to_space_id' => (int)$targetSpace->id, + ] + ); + + if (!$auditSaved) { + $transaction->rollBack(); + $this->addError('to_space_id', Yii::t('AnimalManagementModule.base', 'Could not write transfer audit log.')); + return false; + } + + $transaction->commit(); + } catch (\Throwable $e) { + $transaction->rollBack(); + Yii::error($e, 'animal_management.transfer_request'); + $this->addError('to_space_id', Yii::t('AnimalManagementModule.base', 'Could not create transfer request.')); + return false; + } + + $this->transfer = $transfer; + $this->notifyTargetManagers(); + return true; + } + + private function notifyTargetManagers(): void + { + $targetSpace = Space::findOne(['id' => $this->to_space_id]); + if (!$targetSpace instanceof Space) { + return; + } + + $recipients = TransferNotifier::privilegedUsersForSpace($targetSpace); + + $originator = Yii::$app->user->isGuest ? null : Yii::$app->user->getIdentity(); + + foreach ($recipients as $recipient) { + if ($originator instanceof \humhub\modules\user\models\User && (int)$recipient->id === (int)$originator->id) { + continue; + } + + $notification = TransferRequestedNotification::instance(); + if ($originator) { + $notification->from($originator); + } + $notification->animalName = $this->animal->getDisplayName(); + $notification->fromSpaceName = (string)$this->sourceSpace->name; + $notification->toSpaceGuid = (string)$targetSpace->guid; + $notification->payload([ + 'animalName' => $notification->animalName, + 'fromSpaceName' => $notification->fromSpaceName, + 'toSpaceGuid' => $notification->toSpaceGuid, + ]); + $notification->send($recipient); + } + } +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..94d3ff6 --- /dev/null +++ b/module.json @@ -0,0 +1,15 @@ +{ + "id": "animal_management", + "name": "Animal Management", + "description": "Animal intake, profile management, and transfer workflow foundation for rescue spaces.", + "keywords": ["animals", "rescue", "intake", "transfer"], + "version": "0.1.0", + "humhub": { + "minVersion": "1.14" + }, + "authors": [ + { + "name": "Kelin Rescue Hub" + } + ] +} diff --git a/notifications/TransferNotificationCategory.php b/notifications/TransferNotificationCategory.php new file mode 100644 index 0000000..f5e7c12 --- /dev/null +++ b/notifications/TransferNotificationCategory.php @@ -0,0 +1,21 @@ +animal; + if ($animal === null) { + return 0; + } + + $fromSpace = $transfer->getFromSpace(); + $toSpace = $transfer->getToSpace(); + if (!$fromSpace instanceof Space || !$toSpace instanceof Space) { + return 0; + } + + $targetSpaces = static::targetSpacesForEvent($eventType, $fromSpace, $toSpace); + $sentCount = 0; + $seenRecipients = []; + + foreach ($targetSpaces as $space) { + $recipients = static::privilegedUsersForSpace($space); + foreach ($recipients as $recipient) { + $recipientId = (int)$recipient->id; + if ($originator instanceof User && $recipientId === (int)$originator->id) { + continue; + } + + if (isset($seenRecipients[$recipientId])) { + continue; + } + + $seenRecipients[$recipientId] = true; + $notification = TransferStatusNotification::instance(); + if ($originator instanceof User) { + $notification->from($originator); + } + $notification->animalName = $animal->getDisplayName(); + $notification->fromSpaceName = (string)$fromSpace->name; + $notification->toSpaceName = (string)$toSpace->name; + $notification->spaceGuid = (string)$space->guid; + $notification->eventType = $eventType; + $notification->details = $details; + $notification->payload([ + 'animalName' => $notification->animalName, + 'fromSpaceName' => $notification->fromSpaceName, + 'toSpaceName' => $notification->toSpaceName, + 'spaceGuid' => $notification->spaceGuid, + 'eventType' => $notification->eventType, + 'details' => $notification->details, + ]); + $notification->send($recipient); + $sentCount++; + } + } + + return $sentCount; + } + + public static function privilegedUsersForSpace(Space $space): array + { + $recipients = []; + + foreach ($space->getPrivilegedGroupUsers() as $users) { + foreach ($users as $user) { + if ($user instanceof User && (int)$user->status === User::STATUS_ENABLED) { + $recipients[(int)$user->id] = $user; + } + } + } + + if (empty($recipients)) { + $owner = $space->getOwnerUser()->one(); + if ($owner instanceof User && (int)$owner->status === User::STATUS_ENABLED) { + $recipients[(int)$owner->id] = $owner; + } + } + + return $recipients; + } + + private static function targetSpacesForEvent(string $eventType, Space $fromSpace, Space $toSpace): array + { + $spaces = []; + + switch ($eventType) { + case 'accepted': + case 'declined': + $spaces[] = $fromSpace; + break; + case 'cancelled': + $spaces[] = $toSpace; + break; + case 'completed': + $spaces[] = $fromSpace; + break; + default: + $spaces[] = $fromSpace; + $spaces[] = $toSpace; + break; + } + + $unique = []; + foreach ($spaces as $space) { + $key = (int)$space->contentcontainer_id; + if (!isset($unique[$key])) { + $unique[$key] = $space; + } + } + + return array_values($unique); + } +} diff --git a/notifications/TransferRequestedNotification.php b/notifications/TransferRequestedNotification.php new file mode 100644 index 0000000..a84990a --- /dev/null +++ b/notifications/TransferRequestedNotification.php @@ -0,0 +1,109 @@ +payloadString('toSpaceGuid', $this->toSpaceGuid); + $space = Space::findOne(['guid' => $toSpaceGuid]); + if ($space instanceof Space) { + return $space->createUrl('/animal_management/animals/index') . '#incoming-transfers'; + } + + return Url::to(['/animal_management/animals/index', 'sguid' => $toSpaceGuid]) . '#incoming-transfers'; + } + + public function html() + { + $animalName = $this->payloadString('animalName', $this->animalName); + + if ($this->originator) { + return Yii::t('AnimalManagementModule.base', '{displayName} requested to transfer {animalName}.', [ + 'displayName' => Html::tag('strong', Html::encode($this->originator->displayName)), + 'animalName' => Html::tag('strong', Html::encode($animalName)), + ]); + } + + return Yii::t('AnimalManagementModule.base', 'A transfer was requested for {animalName}.', [ + 'animalName' => Html::tag('strong', Html::encode($animalName)), + ]); + } + + public function getMailSubject() + { + $animalName = $this->payloadString('animalName', $this->animalName); + + return Yii::t('AnimalManagementModule.base', 'Animal transfer request: {animalName}', [ + 'animalName' => $animalName, + ]); + } + + public function __serialize(): array + { + $data = parent::__serialize(); + $data['animalName'] = $this->animalName; + $data['fromSpaceName'] = $this->fromSpaceName; + $data['toSpaceGuid'] = $this->toSpaceGuid; + $data['payload'] = $this->payload; + + return $data; + } + + public function __unserialize($unserializedArr) + { + parent::__unserialize($unserializedArr); + + $this->animalName = (string)($unserializedArr['animalName'] ?? ''); + $this->fromSpaceName = (string)($unserializedArr['fromSpaceName'] ?? ''); + $this->toSpaceGuid = (string)($unserializedArr['toSpaceGuid'] ?? ''); + + if (isset($unserializedArr['payload']) && is_array($unserializedArr['payload'])) { + $this->payload = $unserializedArr['payload']; + } + } + + private function payloadString(string $key, string $fallback = ''): string + { + if (is_array($this->payload) && array_key_exists($key, $this->payload)) { + return trim((string)$this->payload[$key]); + } + + if ($this->record !== null && !empty($this->record->payload)) { + try { + $decoded = Json::decode((string)$this->record->payload); + if (is_array($decoded)) { + $this->payload = $decoded; + if (array_key_exists($key, $decoded)) { + return trim((string)$decoded[$key]); + } + } + } catch (\Throwable $e) { + // Fall back to explicit property values when payload is unavailable. + } + } + + return trim($fallback); + } +} diff --git a/notifications/TransferStatusNotification.php b/notifications/TransferStatusNotification.php new file mode 100644 index 0000000..553fce9 --- /dev/null +++ b/notifications/TransferStatusNotification.php @@ -0,0 +1,162 @@ +payloadString('eventType', $this->eventType); + $spaceGuid = $this->payloadString('spaceGuid', $this->spaceGuid); + $anchor = static::eventAnchor($eventType); + $space = Space::findOne(['guid' => $spaceGuid]); + if ($space instanceof Space) { + return $space->createUrl('/animal_management/animals/index') . '#' . $anchor; + } + + return Url::to(['/animal_management/animals/index', 'sguid' => $spaceGuid]) . '#' . $anchor; + } + + public function html() + { + $animalName = $this->payloadString('animalName', $this->animalName); + $eventType = $this->payloadString('eventType', $this->eventType); + $details = $this->payloadString('details', $this->details); + + $params = [ + 'animalName' => Html::tag('strong', Html::encode($animalName)), + 'status' => Html::encode(static::statusLabel($eventType)), + ]; + + if ($this->originator) { + $params['displayName'] = Html::tag('strong', Html::encode($this->originator->displayName)); + $message = Yii::t('AnimalManagementModule.base', '{displayName} {status} the transfer of {animalName}.', $params); + } else { + $message = Yii::t('AnimalManagementModule.base', '{status} the transfer of {animalName}.', $params); + } + + if ($details !== '') { + $message .= ' ' . Html::encode($details); + } + + return $message; + } + + public function getMailSubject() + { + $animalName = $this->payloadString('animalName', $this->animalName); + $eventType = $this->payloadString('eventType', $this->eventType); + + return Yii::t('AnimalManagementModule.base', 'Animal transfer update: {animalName} ({status})', [ + 'animalName' => $animalName, + 'status' => static::statusLabel($eventType), + ]); + } + + public function __serialize(): array + { + $data = parent::__serialize(); + $data['animalName'] = $this->animalName; + $data['fromSpaceName'] = $this->fromSpaceName; + $data['toSpaceName'] = $this->toSpaceName; + $data['spaceGuid'] = $this->spaceGuid; + $data['eventType'] = $this->eventType; + $data['details'] = $this->details; + $data['payload'] = $this->payload; + + return $data; + } + + public function __unserialize($unserializedArr) + { + parent::__unserialize($unserializedArr); + + $this->animalName = (string)($unserializedArr['animalName'] ?? ''); + $this->fromSpaceName = (string)($unserializedArr['fromSpaceName'] ?? ''); + $this->toSpaceName = (string)($unserializedArr['toSpaceName'] ?? ''); + $this->spaceGuid = (string)($unserializedArr['spaceGuid'] ?? ''); + $this->eventType = (string)($unserializedArr['eventType'] ?? ''); + $this->details = (string)($unserializedArr['details'] ?? ''); + + if (isset($unserializedArr['payload']) && is_array($unserializedArr['payload'])) { + $this->payload = $unserializedArr['payload']; + } + } + + public static function statusLabel(string $eventType): string + { + switch ($eventType) { + case 'accepted': + return Yii::t('AnimalManagementModule.base', 'Accepted'); + case 'declined': + return Yii::t('AnimalManagementModule.base', 'Declined'); + case 'completed': + return Yii::t('AnimalManagementModule.base', 'Completed'); + case 'cancelled': + return Yii::t('AnimalManagementModule.base', 'Cancelled'); + default: + return Yii::t('AnimalManagementModule.base', 'Updated'); + } + } + + private static function eventAnchor(string $eventType): string + { + switch ($eventType) { + case 'requested': + case 'cancelled': + return 'incoming-transfers'; + case 'accepted': + case 'declined': + case 'completed': + return 'outgoing-transfers'; + default: + return 'outgoing-transfers'; + } + } + + private function payloadString(string $key, string $fallback = ''): string + { + if (is_array($this->payload) && array_key_exists($key, $this->payload)) { + return trim((string)$this->payload[$key]); + } + + if ($this->record !== null && !empty($this->record->payload)) { + try { + $decoded = Json::decode((string)$this->record->payload); + if (is_array($decoded)) { + $this->payload = $decoded; + if (array_key_exists($key, $decoded)) { + return trim((string)$decoded[$key]); + } + } + } catch (\Throwable $e) { + // Fall back to explicit property values when payload is unavailable. + } + } + + return trim($fallback); + } +} diff --git a/permissions/ManageAnimals.php b/permissions/ManageAnimals.php new file mode 100644 index 0000000..5752618 --- /dev/null +++ b/permissions/ManageAnimals.php @@ -0,0 +1,33 @@ + PDO::ERRMODE_EXCEPTION, +]); + +$animals = $pdo->query('SELECT id FROM rescue_animal ORDER BY id')->fetchAll(PDO::FETCH_COLUMN); +if (!$animals) { + echo "No animals found\n"; + exit(0); +} + +$coverId = (int)$pdo->query("SELECT id FROM rescue_field_definition WHERE module_id='animal_management' AND field_key='cover_image_url' LIMIT 1")->fetchColumn(); +$profileId = (int)$pdo->query("SELECT id FROM rescue_field_definition WHERE module_id='animal_management' AND field_key='profile_image_url' LIMIT 1")->fetchColumn(); + +$seedDir = '/var/www/localhost/htdocs/uploads/animal-management/seed'; +$seedFiles = glob($seedDir . '/*.jpg'); +sort($seedFiles); + +if (empty($seedFiles)) { + echo "No seed images downloaded\n"; + exit(1); +} + +$now = date('Y-m-d H:i:s'); +$idx = 0; + +$upsert = $pdo->prepare("INSERT INTO rescue_animal_field_value (animal_id, field_definition_id, value_text, value_json, created_at, updated_at) VALUES (:animal_id,:field_definition_id,:value_text,NULL,:created_at,:updated_at) ON DUPLICATE KEY UPDATE value_text=VALUES(value_text),updated_at=VALUES(updated_at)"); +$insertGallery = $pdo->prepare("INSERT INTO rescue_animal_gallery_item (animal_id, file_path, file_id, source_post_id, source_type, caption, created_by, created_at, updated_at) VALUES (:animal_id,:file_path,NULL,NULL,'seed',NULL,NULL,:created_at,:updated_at)"); +$deleteSeedGallery = $pdo->prepare("DELETE FROM rescue_animal_gallery_item WHERE animal_id=:animal_id AND source_type='seed'"); + +foreach ($animals as $animalIdRaw) { + $animalId = (int)$animalIdRaw; + $source = $seedFiles[$idx % count($seedFiles)]; + $idx++; + + $targetDir = '/var/www/localhost/htdocs/uploads/animal-management/animals/' . $animalId; + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { + echo "Could not create directory for animal {$animalId}\n"; + continue; + } + + $stamp = time() . '-' . $animalId; + $coverName = 'cover-seed-' . $stamp . '.jpg'; + $profileName = 'profile-seed-' . $stamp . '.jpg'; + $galleryName = 'gallery-seed-' . $stamp . '.jpg'; + + $coverPath = $targetDir . '/' . $coverName; + $profilePath = $targetDir . '/' . $profileName; + $galleryPath = $targetDir . '/' . $galleryName; + + if (!copy($source, $coverPath) || !copy($source, $profilePath) || !copy($source, $galleryPath)) { + echo "Could not copy seed image for animal {$animalId}\n"; + continue; + } + + $coverUrl = '/uploads/animal-management/animals/' . $animalId . '/' . $coverName; + $profileUrl = '/uploads/animal-management/animals/' . $animalId . '/' . $profileName; + $galleryUrl = '/uploads/animal-management/animals/' . $animalId . '/' . $galleryName; + + if ($coverId > 0) { + $upsert->execute([ + ':animal_id' => $animalId, + ':field_definition_id' => $coverId, + ':value_text' => $coverUrl, + ':created_at' => $now, + ':updated_at' => $now, + ]); + } + + if ($profileId > 0) { + $upsert->execute([ + ':animal_id' => $animalId, + ':field_definition_id' => $profileId, + ':value_text' => $profileUrl, + ':created_at' => $now, + ':updated_at' => $now, + ]); + } + + $deleteSeedGallery->execute([':animal_id' => $animalId]); + $insertGallery->execute([ + ':animal_id' => $animalId, + ':file_path' => $galleryUrl, + ':created_at' => $now, + ':updated_at' => $now, + ]); + + echo "Seeded animal {$animalId} with " . basename($source) . "\n"; +} diff --git a/services/AnimalStreamPublisherService.php b/services/AnimalStreamPublisherService.php new file mode 100644 index 0000000..b396516 --- /dev/null +++ b/services/AnimalStreamPublisherService.php @@ -0,0 +1,87 @@ +where([ + 'entry_type' => AnimalStreamEntry::TYPE_MEDICAL, + 'medical_visit_id' => (int)$visit->id, + ])->exists(); + + if ($exists) { + return; + } + + self::publishEntry($animal, AnimalStreamEntry::TYPE_MEDICAL, (int)$visit->id, null); + } + + public static function publishProgressUpdate(Animal $animal, AnimalProgressUpdate $update): void + { + if (!self::streamTableExists()) { + return; + } + + $exists = AnimalStreamEntry::find()->where([ + 'entry_type' => AnimalStreamEntry::TYPE_PROGRESS, + 'progress_update_id' => (int)$update->id, + ])->exists(); + + if ($exists) { + return; + } + + self::publishEntry($animal, AnimalStreamEntry::TYPE_PROGRESS, null, (int)$update->id); + } + + private static function publishEntry(Animal $animal, string $entryType, ?int $medicalVisitId, ?int $progressUpdateId): void + { + try { + $space = Space::findOne(['contentcontainer_id' => (int)$animal->contentcontainer_id]); + if (!$space instanceof Space) { + return; + } + + $entry = new AnimalStreamEntry(); + $entry->animal_id = (int)$animal->id; + $entry->entry_type = $entryType; + $entry->medical_visit_id = $medicalVisitId; + $entry->progress_update_id = $progressUpdateId; + $entry->content->container = $space; + + if (!$entry->save()) { + Yii::warning([ + 'message' => 'Could not save animal stream entry.', + 'animal_id' => (int)$animal->id, + 'entry_type' => $entryType, + 'errors' => $entry->getErrors(), + ], 'animal_management.stream_publish'); + } + } catch (\Throwable $e) { + Yii::warning([ + 'message' => 'Unexpected error while publishing animal stream entry.', + 'animal_id' => (int)$animal->id, + 'entry_type' => $entryType, + 'exception' => $e->getMessage(), + ], 'animal_management.stream_publish'); + } + } + + private static function streamTableExists(): bool + { + return Yii::$app->db->schema->getTableSchema('rescue_animal_stream_entry', true) !== null; + } +} diff --git a/services/GalleryIntegrationService.php b/services/GalleryIntegrationService.php new file mode 100644 index 0000000..487d803 --- /dev/null +++ b/services/GalleryIntegrationService.php @@ -0,0 +1,343 @@ +moduleManager->isEnabled('animal_management')) { + return false; + } + + if (!$space->moduleManager->isEnabled('gallery')) { + return false; + } + + return self::hasRequiredTables(); + } + + public static function syncSpaceAnimalGalleries(Space $space): void + { + if (!self::canSyncForSpace($space)) { + return; + } + + $animals = Animal::find()->where(['contentcontainer_id' => (int)$space->contentcontainer_id])->all(); + $synced = 0; + foreach ($animals as $animal) { + if ($animal instanceof Animal) { + if (self::ensureAnimalGallery($animal, $space) instanceof CustomGallery) { + $synced++; + } + } + } + + Yii::warning([ + 'space_id' => (int)$space->id, + 'contentcontainer_id' => (int)$space->contentcontainer_id, + 'animal_count' => count($animals), + 'synced_count' => $synced, + 'has_link_table' => self::hasLinkTable(), + ], 'animal_management.gallery_integration.sync'); + } + + public static function ensureAnimalGallery(Animal $animal, ?Space $space = null): ?CustomGallery + { + if (!self::hasRequiredTables()) { + return null; + } + + $space = $space ?: Space::findOne(['contentcontainer_id' => (int)$animal->contentcontainer_id]); + if (!$space instanceof Space || !self::canSyncForSpace($space)) { + return null; + } + + $link = self::hasLinkTable() ? AnimalGalleryLink::findOne(['animal_id' => (int)$animal->id]) : null; + $gallery = null; + + if ($link instanceof AnimalGalleryLink) { + $gallery = CustomGallery::find() + ->contentContainer($space) + ->where(['gallery_gallery.id' => (int)$link->gallery_id]) + ->one(); + } + + if (!$gallery instanceof CustomGallery) { + $gallery = CustomGallery::find() + ->contentContainer($space) + ->where(['like', 'description', self::buildMarker((int)$animal->id)]) + ->one(); + + if (!$gallery instanceof CustomGallery) { + $gallery = CustomGallery::find() + ->contentContainer($space) + ->where(['title' => self::buildTitle($animal)]) + ->one(); + } + } + + if (!$gallery instanceof CustomGallery) { + $gallery = new CustomGallery(); + $gallery->content->container = $space; + $gallery->content->visibility = (int)$space->getDefaultContentVisibility(); + $gallery->title = self::buildTitle($animal); + $gallery->description = self::buildDescription($animal); + + try { + if (!$gallery->save()) { + Yii::warning($gallery->getErrors(), 'animal_management.gallery_integration.gallery_create_failed'); + return null; + } + } catch (\Throwable $e) { + Yii::warning($e->getMessage(), 'animal_management.gallery_integration.gallery_create_failed'); + return null; + } + } else { + $newTitle = self::buildTitle($animal); + $newDescription = self::buildDescription($animal); + $dirty = false; + + if ((string)$gallery->title !== $newTitle) { + $gallery->title = $newTitle; + $dirty = true; + } + + if ((string)$gallery->description !== $newDescription) { + $gallery->description = $newDescription; + $dirty = true; + } + + if ($dirty) { + try { + $gallery->save(); + } catch (\Throwable $e) { + Yii::warning($e->getMessage(), 'animal_management.gallery_integration.gallery_update_failed'); + } + } + } + + if (self::hasLinkTable()) { + if (!$link instanceof AnimalGalleryLink) { + $link = new AnimalGalleryLink(); + $link->animal_id = (int)$animal->id; + } + + $link->gallery_id = (int)$gallery->id; + $link->contentcontainer_id = (int)$space->contentcontainer_id; + try { + if (!$link->save()) { + Yii::warning($link->getErrors(), 'animal_management.gallery_integration.link_save_failed'); + } + } catch (\Throwable $e) { + Yii::warning($e->getMessage(), 'animal_management.gallery_integration.link_save_failed'); + } + } + + self::refreshGalleryThumb($animal, $gallery); + return $gallery; + } + + public static function isAnimalBackedGallery(CustomGallery $gallery): bool + { + return self::getAnimalByGalleryId((int)$gallery->id) instanceof Animal; + } + + public static function getAnimalByGalleryId(int $galleryId): ?Animal + { + if (!self::hasRequiredTables()) { + return null; + } + + if (self::hasLinkTable()) { + $link = AnimalGalleryLink::findOne(['gallery_id' => $galleryId]); + if ($link instanceof AnimalGalleryLink) { + return Animal::findOne(['id' => (int)$link->animal_id]); + } + } + + $gallery = CustomGallery::findOne(['id' => $galleryId]); + if (!$gallery instanceof CustomGallery) { + return null; + } + + $animalId = self::extractAnimalIdFromDescription((string)$gallery->description); + if ($animalId === null) { + $animalId = self::extractAnimalIdFromTitle($gallery); + if ($animalId === null) { + return null; + } + } + + return Animal::findOne(['id' => $animalId]); + } + + public static function getAnimalItemsQuery(CustomGallery $gallery) + { + $animal = self::getAnimalByGalleryId((int)$gallery->id); + if (!$animal instanceof Animal) { + Yii::warning([ + 'gallery_id' => (int)$gallery->id, + 'title' => (string)$gallery->title, + 'description' => (string)$gallery->description, + ], 'animal_management.gallery_integration.gallery_unmapped'); + return null; + } + + return AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id]) + ->andWhere([ + 'or', + ['and', ['not', ['file_path' => null]], ['!=', 'file_path', '']], + ['not', ['file_id' => null]], + ]) + ->orderBy(['id' => SORT_DESC]); + } + + public static function getAnimalPreviewUrl(CustomGallery $gallery): ?string + { + $animal = self::getAnimalByGalleryId((int)$gallery->id); + if (!$animal instanceof Animal) { + return null; + } + + $item = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id]) + ->orderBy(['id' => SORT_DESC]) + ->one(); + + if (!$item instanceof AnimalGalleryItem) { + return null; + } + + $url = trim((string)$item->getImageUrl()); + return $url !== '' ? $url : null; + } + + public static function isAnimalGalleryEmpty(CustomGallery $gallery): ?bool + { + $animal = self::getAnimalByGalleryId((int)$gallery->id); + if (!$animal instanceof Animal) { + return null; + } + + return !AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->exists(); + } + + private static function refreshGalleryThumb(Animal $animal, CustomGallery $gallery): void + { + $thumbFileId = AnimalGalleryItem::find() + ->where(['animal_id' => (int)$animal->id]) + ->andWhere(['not', ['file_id' => null]]) + ->orderBy(['id' => SORT_DESC]) + ->select('file_id') + ->scalar(); + + $thumbFileId = $thumbFileId === null ? null : (int)$thumbFileId; + if ((int)($gallery->thumb_file_id ?? 0) === (int)($thumbFileId ?? 0)) { + return; + } + + $gallery->thumb_file_id = $thumbFileId; + $gallery->save(false, ['thumb_file_id']); + } + + private static function hasRequiredTables(): bool + { + $schema = Yii::$app->db->schema; + + return $schema->getTableSchema('rescue_animal', true) !== null + && $schema->getTableSchema('rescue_animal_gallery_item', true) !== null + && $schema->getTableSchema('gallery_gallery', true) !== null; + } + + private static function hasLinkTable(): bool + { + return Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_link', true) !== null; + } + + private static function buildTitle(Animal $animal): string + { + return $animal->getDisplayName() . ' Gallery'; + } + + private static function buildDescription(Animal $animal): string + { + return self::buildMarker((int)$animal->id); + } + + private static function buildMarker(int $animalId): string + { + return '[animal-gallery:' . $animalId . ']'; + } + + private static function extractAnimalIdFromDescription(string $description): ?int + { + if (!preg_match('/\[animal-gallery:(\d+)\]/', $description, $matches)) { + return null; + } + + return isset($matches[1]) ? (int)$matches[1] : null; + } + + private static function extractAnimalIdFromTitle(CustomGallery $gallery): ?int + { + $container = $gallery->content->container ?? null; + if (!$container instanceof Space) { + return null; + } + + $title = trim((string)$gallery->title); + if ($title === '') { + return null; + } + + $titleNormalized = self::normalizeTitleToken($title); + if ($titleNormalized === '') { + return null; + } + + $animals = Animal::find() + ->where(['contentcontainer_id' => (int)$container->contentcontainer_id]) + ->orderBy(['id' => SORT_ASC]) + ->all(); + + foreach ($animals as $animal) { + if (!$animal instanceof Animal) { + continue; + } + + $nameNormalized = self::normalizeTitleToken((string)$animal->name); + if ($nameNormalized !== '' && ($nameNormalized === $titleNormalized || strpos($titleNormalized, $nameNormalized) !== false)) { + return (int)$animal->id; + } + + $uidNormalized = self::normalizeTitleToken((string)$animal->animal_uid); + if ($uidNormalized !== '' && ($uidNormalized === $titleNormalized || strpos($titleNormalized, $uidNormalized) !== false)) { + return (int)$animal->id; + } + } + + return null; + } + + private static function normalizeTitleToken(string $value): string + { + $value = strtolower(trim($value)); + if ($value === '') { + return ''; + } + + $value = preg_replace('/\bgallery\b/i', ' ', $value); + $value = preg_replace('/[^a-z0-9]+/i', ' ', (string)$value); + return trim((string)$value); + } + +} diff --git a/views/animals/_tile.php b/views/animals/_tile.php new file mode 100644 index 0000000..5a494b0 --- /dev/null +++ b/views/animals/_tile.php @@ -0,0 +1,93 @@ +status] ?? (string)$animal->status; +$fieldMap = [ + 'name' => (string)$animal->getDisplayName(), + 'species' => (string)$animal->species, + 'breed' => (string)$animal->breed, + 'sex' => (string)$animal->sex, + 'status' => (string)$statusLabel, + 'location_name' => (string)$animal->location_name, + 'animal_uid' => (string)$animal->animal_uid, + 'public_summary' => trim((string)$animal->public_summary), + 'last_medical' => $lastMedical instanceof AnimalMedicalVisit ? DateDisplayHelper::format((string)$lastMedical->visit_at) : '', +]; + +$tileFields = is_array($tileFields) ? $tileFields : []; +$selectedMeta = []; +foreach ($tileFields as $fieldKey) { + $fieldKey = trim((string)$fieldKey); + if ($fieldKey === '' || $fieldKey === 'name' || !array_key_exists($fieldKey, $fieldMap)) { + continue; + } + + $value = trim((string)$fieldMap[$fieldKey]); + if ($value === '') { + continue; + } + + $selectedMeta[] = $value; +} + +$summaryText = trim((string)$fieldMap['public_summary']); +if ($summaryText !== '') { + $summaryText = substr($summaryText, 0, 160) . (strlen($summaryText) > 160 ? '...' : ''); +} + +$animalViewUrl = $contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]); +$medicalUrl = $contentContainer->createUrl('/animal_management/animals/medical-visits', ['id' => $animal->id]); +?> + +
+
+ + + <?= Html::encode($animal->getDisplayName()) ?> + +
+ +
+
+ + getDisplayName()) ?> + + + + + + +
+ +
+ + + + + +
+ + +
+ +
+ +
+
+
diff --git a/views/animals/_transfer_tile.php b/views/animals/_transfer_tile.php new file mode 100644 index 0000000..0bb94b8 --- /dev/null +++ b/views/animals/_transfer_tile.php @@ -0,0 +1,130 @@ +animal ? $transfer->animal->getDisplayName() : ('#' . (int)$transfer->animal_id); +$statusLabel = AnimalTransfer::statusOptions()[$transfer->status] ?? (string)$transfer->status; +$hasImage = $imageUrl !== '' && (preg_match('/^https?:\/\//i', $imageUrl) || substr($imageUrl, 0, 1) === '/'); +$statusTextColor = '#ffffff'; +switch ($transfer->status) { + case AnimalTransfer::STATUS_REQUESTED: + $statusTextColor = '#facc15'; + break; + case AnimalTransfer::STATUS_ACCEPTED: + $statusTextColor = '#4ade80'; + break; + case AnimalTransfer::STATUS_COMPLETED: + $statusTextColor = '#60a5fa'; + break; + case AnimalTransfer::STATUS_DECLINED: + $statusTextColor = '#fca5a5'; + break; + case AnimalTransfer::STATUS_CANCELLED: + $statusTextColor = '#d1d5db'; + break; +} +?> + +
+
+ + <?= Html::encode($animalName) ?> + +
+ +
+ + +
+ +
+ + 'display:inline-block;background:rgba(15,23,42,0.66);border:1px solid rgba(255,255,255,0.28);color:#fff;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-decoration:none;' + ] + ) ?> + + + + + +
+ +
+ status === AnimalTransfer::STATUS_REQUESTED): ?> + createUrl('/animal_management/animals/transfer-respond', ['id' => $transfer->id, 'decision' => 'accept']), + [ + 'class' => 'btn btn-xs btn-default', + 'data-method' => 'post', + 'style' => 'border-radius:999px;border:0;background:rgba(255,255,255,0.92);font-weight:600;' + ] + ) ?> + createUrl('/animal_management/animals/transfer-respond', ['id' => $transfer->id, 'decision' => 'decline']), + [ + 'class' => 'btn btn-xs btn-default', + 'data-method' => 'post', + 'style' => 'border-radius:999px;border:0;background:rgba(255,255,255,0.92);font-weight:600;' + ] + ) ?> + status === AnimalTransfer::STATUS_ACCEPTED): ?> + createUrl('/animal_management/animals/transfer-complete', ['id' => $transfer->id]), + [ + 'class' => 'btn btn-xs btn-default', + 'data-method' => 'post', + 'style' => 'border-radius:999px;border:0;background:rgba(255,255,255,0.92);font-weight:600;' + ] + ) ?> + status, [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED], true)): ?> + createUrl('/animal_management/animals/transfer-cancel', ['id' => $transfer->id]), + [ + 'class' => 'btn btn-xs btn-default', + 'data-method' => 'post', + 'style' => 'border-radius:999px;border:0;background:rgba(255,255,255,0.92);font-weight:600;' + ] + ) ?> + +
+ +
+
+
+ + 'color:#fff;text-decoration:none;' + ] + ) ?> + + + +
+ + + +
+
+
+
diff --git a/views/animals/add-medical-visit.php b/views/animals/add-medical-visit.php new file mode 100644 index 0000000..a47ba09 --- /dev/null +++ b/views/animals/add-medical-visit.php @@ -0,0 +1,518 @@ + 'BP', + 'oxygen' => 'O₂', + ]; + $label = (string)($vitalLabelOverrides[$fieldKey] ?? $definition['label']); + if ((int)$definition['required'] === 1) { + $label .= ' *'; + } + + $fieldName = "AnimalMedicalVisitForm[customFields][$fieldKey]"; + $fieldValue = $formModel->customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalmedicalvisitform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownMedicalKeys = [ + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'medical_media_reference', + 'media_reference', +]; + +$remainingDefinitions = []; +foreach ($customDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownMedicalKeys, true) || in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + $remainingDefinitions[$fieldKey] = $definition; +} + +$medicalMediaPath = trim((string)($model->customFields['medical_media_reference'] ?? $model->customFields['media_reference'] ?? '')); +$hasMedicalMedia = $medicalMediaPath !== '' && (preg_match('/^https?:\/\//i', $medicalMediaPath) || substr($medicalMediaPath, 0, 1) === '/'); +$medicalGalleryModalId = 'add-medical-media-gallery-modal'; +$medicalFormId = 'add-medical-visit-inline-form'; + + $this->registerCss(<< .panel-body { + position: relative; + z-index: 1; + background: rgba(10, 18, 28, 0.2); +} + +.inline-add-shell .panel.panel-default { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(10, 18, 28, 0.34); +} + +.inline-add-shell .panel.panel-default > .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-add-shell, +.inline-add-shell .panel-body, +.inline-add-shell .control-label, +.inline-add-shell .checkbox label, +.inline-add-shell .radio label, +.inline-add-shell .help-block { + color: #eef5fb; +} + +.inline-add-shell .text-muted { + color: rgba(233, 242, 250, 0.78) !important; +} + +.inline-add-shell .form-control { + background: rgba(10, 18, 28, 0.56); + border-color: rgba(255, 255, 255, 0.44); + color: #f3f8ff; +} + +.inline-add-shell .form-control::placeholder { + color: rgba(243, 248, 255, 0.72); +} + +.inline-add-shell .form-control[readonly], +.inline-add-shell .form-control[disabled] { + background: rgba(10, 18, 28, 0.42); + color: rgba(243, 248, 255, 0.72); +} + +.inline-add-shell select.form-control option { + color: #0f1b2a; +} +CSS +); + +if ($isInline) { + $this->registerCss(<< .panel:first-child { + margin-top: 0 !important; +} +CSS + ); +} +?> + + + +
> +
+ $medicalFormId, 'enctype' => 'multipart/form-data']; + if (!$isInline) { + $formOptions['target'] = '_top'; + } + $form = ActiveForm::begin(['options' => $formOptions]); + ?> + + 'medical-media-gallery-path']) ?> + + +
+ ', [ + 'class' => 'btn btn-default btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Save Medical Visit'), + 'form' => $medicalFormId, + ]) ?> + ', [ + 'type' => 'button', + 'class' => 'btn btn-default btn-sm', + 'id' => 'medical-inline-add-cancel-icon', + 'title' => Yii::t('AnimalManagementModule.base', 'Cancel'), + ]) ?> +
+ + + errorSummary($model, ['showAllErrors' => true]) ?> + +
+
+
+
+
+
+ + <?= Yii::t('AnimalManagementModule.base', 'Selected medical media') ?> + + + +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
field($model, 'visit_at')->input('datetime-local') ?>
+
field($model, 'provider_name') ?>
+
+ field($model, 'notes')->textarea(['rows' => 3]) ?> + field($model, 'recommendations')->textarea(['rows' => 3]) ?> +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ field($model, 'post_to_space_feed')->checkbox() ?> + field($model, 'post_to_animal_feed')->checkbox() ?> +
+
+ + +
+
+
+ $definition): ?> + + +
+
+ + + submit() ?> + + 'button', + 'class' => 'btn btn-default', + 'id' => 'medical-inline-add-cancel', + ]) ?> + + link(($returnTo ?? 'medical-visits') === 'medical-visits' + ? $space->createUrl('/animal_management/animals/medical-visits', ['id' => $animal->id]) + : $space->createUrl('/animal_management/animals/view', ['id' => $animal->id])) ?> + + + +
+
+ + + +registerJs(<<'); + } else { + preview.html(''); + } + + setMedicalShellBackground(source); + } + + function markSelectedMedicalThumb(value) { + $('.medical-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.medical-media-select-thumb').each(function() { + if ($(this).data('media-url') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).on('click', '.medical-media-select-thumb', function() { + var mediaUrl = $(this).data('media-url'); + $('#medical-media-gallery-path').val(mediaUrl); + markSelectedMedicalThumb(mediaUrl); + $('#medicalMediaUpload').val(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + if (mediaUrl) { + renderMedicalPreview(mediaUrl); + } + $('#{$medicalGalleryModalId}').modal('hide'); + }); + + $('#medicalMediaUpload').on('change', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#medical-media-gallery-path').val(''); + markSelectedMedicalThumb(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + renderMedicalPreview(e.target.result); + $('#{$medicalGalleryModalId}').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $('#{$medicalGalleryModalId}').on('shown.bs.modal', function() { + markSelectedMedicalThumb($('#medical-media-gallery-path').val()); + }); +})(); +JS +, \yii\web\View::POS_END); + +if ($isInline) { + $cancelPayload = Json::htmlEncode([ + 'source' => 'animal-inline-editor', + 'type' => 'cancel', + 'collapseId' => 'medical-add-inline', + ]); + + $this->registerJs(<< \ No newline at end of file diff --git a/views/animals/add-progress-update.php b/views/animals/add-progress-update.php new file mode 100644 index 0000000..7108843 --- /dev/null +++ b/views/animals/add-progress-update.php @@ -0,0 +1,445 @@ +customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalprogressupdateform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownProgressKeys = ['progress_notes', 'routine_updates', 'media_reference']; +$otherCustomDefinitions = []; +foreach ($customDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownProgressKeys, true)) { + continue; + } + $otherCustomDefinitions[$fieldKey] = $definition; +} + +$currentMediaReference = trim((string)($model->customFields['media_reference'] ?? '')); +$progressFormId = 'add-progress-update-inline-form'; + +$this->registerCss(<< .panel-body { + position: relative; + z-index: 1; + background: rgba(10, 18, 28, 0.2); +} + +.inline-add-shell .panel.panel-default { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(10, 18, 28, 0.34); +} + +.inline-add-shell .panel.panel-default > .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-add-shell, +.inline-add-shell .panel-body, +.inline-add-shell .control-label, +.inline-add-shell .checkbox label, +.inline-add-shell .radio label, +.inline-add-shell .help-block { + color: #eef5fb; +} + +.inline-add-shell .text-muted { + color: rgba(233, 242, 250, 0.78) !important; +} + +.inline-add-shell .form-control { + background: rgba(10, 18, 28, 0.56); + border-color: rgba(255, 255, 255, 0.44); + color: #f3f8ff; +} + +.inline-add-shell .form-control::placeholder { + color: rgba(243, 248, 255, 0.72); +} + +.inline-add-shell .form-control[readonly], +.inline-add-shell .form-control[disabled] { + background: rgba(10, 18, 28, 0.42); + color: rgba(243, 248, 255, 0.72); +} + +.inline-add-shell select.form-control option { + color: #0f1b2a; +} +CSS +); + +if ($isInline) { + $this->registerCss(<< .panel:first-child { + margin-top: 0 !important; +} +CSS + ); +} +?> + +
> +
+ $progressFormId, 'enctype' => 'multipart/form-data']; + if (!$isInline) { + $formOptions['target'] = '_top'; + } + $form = ActiveForm::begin(['options' => $formOptions]); + ?> + + + +
+ ', [ + 'class' => 'btn btn-default btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Save Progress Update'), + 'form' => $progressFormId, + ]) ?> + ', [ + 'type' => 'button', + 'class' => 'btn btn-default btn-sm', + 'id' => 'progress-inline-add-cancel-icon', + 'title' => Yii::t('AnimalManagementModule.base', 'Cancel'), + ]) ?> +
+ + + errorSummary($model, ['showAllErrors' => true]) ?> + +
+
+
+ +
+
+
+ + <?= Yii::t('AnimalManagementModule.base', 'Selected media') ?> + + + +
+
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
field($model, 'weight') ?>
+
field($model, 'vitals')->textInput(['maxlength' => 255]) ?>
+
field($model, 'behavior_notes')->textarea(['rows' => 2]) ?>
+
field($model, 'medical_concerns')->textarea(['rows' => 2]) ?>
+
field($model, 'meal_plan_changes')->textarea(['rows' => 2]) ?>
+
field($model, 'housing_changes')->textarea(['rows' => 2]) ?>
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ $definition): ?> + + +
+
+ + +
+
+
+ field($model, 'post_to_space_feed')->checkbox() ?> + field($model, 'post_to_animal_feed')->checkbox() ?> +
+
+ + + + submit() ?> + + 'button', + 'class' => 'btn btn-default', + 'id' => 'progress-inline-add-cancel', + ]) ?> + + link(($returnTo ?? 'progress-updates') === 'progress-updates' + ? $space->createUrl('/animal_management/animals/progress-updates', ['id' => $animal->id]) + : $space->createUrl('/animal_management/animals/view', ['id' => $animal->id])) ?> + + + +
+
+ +registerCss(<<registerJs(<<'); + } else { + preview.html(''); + } + + setProgressShellBackground(source); + } + + function markSelectedMediaThumb(value) { + $('.progress-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.progress-media-select-thumb').each(function() { + if (($(this).attr('data-media-url') || '') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).off('click.addProgressMediaSelect', '.progress-media-select-thumb').on('click.addProgressMediaSelect', '.progress-media-select-thumb', function() { + var mediaUrl = $(this).attr('data-media-url') || ''; + $('#progress-media-gallery-path').val(mediaUrl); + markSelectedMediaThumb(mediaUrl); + $('#progressMediaUpload').val(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + if (mediaUrl) { + renderProgressPreview(mediaUrl); + } + $('#progress-media-modal').modal('hide'); + }); + + $(document).off('change.addProgressMediaUpload', '#progressMediaUpload').on('change.addProgressMediaUpload', '#progressMediaUpload', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#progress-media-gallery-path').val(''); + markSelectedMediaThumb(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + renderProgressPreview(e.target.result); + $('#progress-media-modal').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $(document).off('shown.bs.modal.addProgressMediaModal', '#progress-media-modal').on('shown.bs.modal.addProgressMediaModal', '#progress-media-modal', function() { + markSelectedMediaThumb($('#progress-media-gallery-path').val()); + }); +})(); +JS +, \yii\web\View::POS_END); + +if ($isInline) { + $cancelPayload = Json::htmlEncode([ + 'source' => 'animal-inline-editor', + 'type' => 'cancel', + 'collapseId' => 'progress-add-inline', + ]); + + $this->registerJs(<< \ No newline at end of file diff --git a/views/animals/create.php b/views/animals/create.php new file mode 100644 index 0000000..cde0925 --- /dev/null +++ b/views/animals/create.php @@ -0,0 +1,534 @@ + + +
+
+ + Edit Animal Profile') ?> + + New Animal Intake') ?> + +
+
+ ['enctype' => 'multipart/form-data']]); ?> + + errorSummary($model, ['showAllErrors' => true]) ?> + + getCustomFieldDefinitions(); + $galleryImageOptions = $model->getGalleryImageOptions(); + $galleryImageUrls = array_keys($galleryImageOptions); + + $knownProfileFieldKeys = [ + 'dob', + 'age', + 'rescue', + 'lineage', + 'backstory', + 'previous_owner_user_id', + 'previous_owner_name', + 'previous_owner_business_name', + 'previous_owner_street_address', + 'previous_owner_city', + 'previous_owner_state', + 'previous_owner_zip', + 'previous_owner_cell_phone', + 'previous_owner_business_phone', + 'previous_owner_email', + ]; + + $renderCustomField = static function (string $fieldKey, AnimalForm $model, array $definitions): string { + if (!isset($definitions[$fieldKey])) { + return ''; + } + + $definition = $definitions[$fieldKey]; + $inputType = (string)$definition['input_type']; + $label = (string)$definition['label']; + if ((int)$definition['required'] === 1) { + $label .= ' *'; + } + + $fieldName = "AnimalForm[customFields][$fieldKey]"; + $fieldValue = $model->customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalform-customfields-$fieldKey"]) ?> +
+ + + +
+
+
+
+ +
+
+
+
+ getExistingCoverImagePath()): ?> + <?= Yii::t('AnimalManagementModule.base', 'Cover Image') ?> + +
+ +
+ + +
+
+
+
+
+ getExistingProfileImagePath()): ?> + <?= Yii::t('AnimalManagementModule.base', 'Profile Image') ?> + +
+ +
+ + +
+
+
+
+ +
+ field($model, 'coverImageGalleryPath')->hiddenInput()->label(false) ?> + field($model, 'profileImageGalleryPath')->hiddenInput()->label(false) ?> + field($model, 'coverImageFile')->fileInput(['accept' => 'image/*']) ?> + field($model, 'profileImageFile')->fileInput(['accept' => 'image/*']) ?> +
+
+
+ +
+
+
+
+
+
+
isFieldActive('name')) { echo $form->field($model, 'name')->textInput(['maxlength' => 190]); } ?>
+
isFieldActive('species')) { echo $form->field($model, 'species')->textInput(['maxlength' => 120]); } ?>
+
isFieldActive('breed')) { echo $form->field($model, 'breed')->textInput(['maxlength' => 120]); } ?>
+
isFieldActive('sex')) { echo $form->field($model, 'sex')->textInput(['maxlength' => 32]); } ?>
+
+
+
isFieldActive('status')) { echo $form->field($model, 'status')->dropDownList($model->getStatusOptions()); } ?>
+
isFieldActive('in_possession')) { echo $form->field($model, 'in_possession')->checkbox(); } ?>
+
+
+
+ +
+
+
+
+
+
isFieldActive('location_name')) { echo $form->field($model, 'location_name')->textInput(['maxlength' => 120]); } ?>
+
isFieldActive('city')) { echo $form->field($model, 'city')->textInput(['maxlength' => 120]); } ?>
+
isFieldActive('state')) { echo $form->field($model, 'state')->textInput(['maxlength' => 2]); } ?>
+
isFieldActive('zip')) { echo $form->field($model, 'zip')->textInput(['maxlength' => 10]); } ?>
+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ isFieldActive('public_summary')): ?> + field($model, 'public_summary')->textarea(['rows' => 4]) ?> + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + tileDisplayFields, DisplaySettingsForm::fieldOptions(), ['separator' => '
']) ?> +
+
+ + heroDisplayFields, DisplaySettingsForm::fieldOptions(), ['separator' => '
']) ?> +
+
+
+
+ + $definition) { + if (in_array($fieldKey, $knownProfileFieldKeys, true)) { + continue; + } + $remainingCustomFields[$fieldKey] = $definition; + } + ?> + + +
+
+
+ $definition): ?> + + +
+
+ +
+
+ + submit() ?> + link( + !empty($isEdit) && $animal instanceof Animal + ? $space->createUrl('/animal_management/animals/view', ['id' => $animal->id]) + : $space->createUrl('/animal_management/animals/index') + ) ?> + + createUrl('/animal_management/animals/delete', ['id' => $animal->id]), + [ + 'class' => 'btn btn-danger pull-right', + 'style' => 'margin-left:8px;', + 'data-method' => 'post', + 'data-confirm' => Yii::t('AnimalManagementModule.base', 'Delete {name}? This will remove the animal record and local gallery uploads. This cannot be undone.', ['name' => $animal->getDisplayName()]), + ] + ) ?> + + + + + + + +
+
+ +registerCss(<<registerJs(<<'; + } + + return '
'; + } + + function updatePreview(kind, value) { + var previewId = kind === 'profile' ? '#animal-profile-preview' : '#animal-cover-preview'; + if (value && (value.indexOf('/') === 0 || /^https?:\/\//i.test(value) || /^data:image\//i.test(value))) { + $(previewId).html('' + kind + ' image' + + ''); + return; + } + + $(previewId).html(renderPlaceholder(kind) + + ''); + } + + function markSelectedThumb(kind, value) { + var modalId = kind === 'profile' ? '#animal-profile-image-manage-modal' : '#animal-cover-image-manage-modal'; + $(modalId + ' .animal-image-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $(modalId + ' .animal-image-select-thumb').each(function() { + if ($(this).data('select-value') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).on('click', '.animal-image-select-thumb', function() { + var kind = $(this).data('select-kind') || 'cover'; + var target = $(this).data('select-target'); + var value = $(this).data('select-value'); + if (target && value !== undefined) { + $(target).val(value); + if (kind === 'profile') { + $('#animalform-profileimagefile').val(''); + $('#animalform-removeprofileimage').prop('checked', false); + } else { + $('#animalform-coverimagefile').val(''); + $('#animalform-removecoverimage').prop('checked', false); + } + updatePreview(kind, value); + markSelectedThumb(kind, value); + if (kind === 'profile') { + $('#animal-profile-upload-file-name').text(''); + } else { + $('#animal-cover-upload-file-name').text(''); + } + } + + $(this).closest('.modal').modal('hide'); + }); + + $('#animal-cover-upload-trigger').on('click', function() { + $('#animalform-coverimagefile').trigger('click'); + }); + + $('#animal-profile-upload-trigger').on('click', function() { + $('#animalform-profileimagefile').trigger('click'); + }); + + $('#animalform-coverimagefile').on('change', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + $('#animalform-coverimagegallerypath').val(''); + $('#animalform-removecoverimage').prop('checked', false); + + if (!file) { + return; + } + + var reader = new FileReader(); + reader.onload = function(e) { + updatePreview('cover', e.target.result); + markSelectedThumb('cover', ''); + }; + reader.readAsDataURL(file); + $('#animal-cover-upload-file-name').text(file.name || ''); + $('#animal-cover-image-manage-modal').modal('hide'); + }); + + $('#animalform-profileimagefile').on('change', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + $('#animalform-profileimagegallerypath').val(''); + $('#animalform-removeprofileimage').prop('checked', false); + + if (!file) { + return; + } + + var reader = new FileReader(); + reader.onload = function(e) { + updatePreview('profile', e.target.result); + markSelectedThumb('profile', ''); + }; + reader.readAsDataURL(file); + $('#animal-profile-upload-file-name').text(file.name || ''); + $('#animal-profile-image-manage-modal').modal('hide'); + }); + + $('#animalform-removecoverimage').on('change', function() { + if ($(this).is(':checked')) { + $('#animalform-coverimagegallerypath').val(''); + $('#animalform-coverimagefile').val(''); + $('#animal-cover-upload-file-name').text(''); + markSelectedThumb('cover', ''); + updatePreview('cover', ''); + } + }); + + $('#animalform-removeprofileimage').on('change', function() { + if ($(this).is(':checked')) { + $('#animalform-profileimagegallerypath').val(''); + $('#animalform-profileimagefile').val(''); + $('#animal-profile-upload-file-name').text(''); + markSelectedThumb('profile', ''); + updatePreview('profile', ''); + } + }); + + $('#animal-cover-image-manage-modal').on('shown.bs.modal', function() { + markSelectedThumb('cover', $('#animalform-coverimagegallerypath').val()); + }); + + $('#animal-profile-image-manage-modal').on('shown.bs.modal', function() { + markSelectedThumb('profile', $('#animalform-profileimagegallerypath').val()); + }); +})(); +JS +); +?> diff --git a/views/animals/edit-medical-visit.php b/views/animals/edit-medical-visit.php new file mode 100644 index 0000000..4ec2df4 --- /dev/null +++ b/views/animals/edit-medical-visit.php @@ -0,0 +1,497 @@ + 'BP', + 'oxygen' => 'O₂', + ]; + $label = (string)($vitalLabelOverrides[$fieldKey] ?? $definition['label']); + if ((int)$definition['required'] === 1) { + $label .= ' *'; + } + + $fieldName = "AnimalMedicalVisitForm[customFields][$fieldKey]"; + $fieldValue = $formModel->customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalmedicalvisitform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownMedicalKeys = [ + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'medical_media_reference', + 'media_reference', +]; + +$remainingDefinitions = []; +foreach ($customDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownMedicalKeys, true) || in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + $remainingDefinitions[$fieldKey] = $definition; +} + +$medicalMediaPath = trim((string)($model->customFields['medical_media_reference'] ?? $model->customFields['media_reference'] ?? '')); +$hasMedicalMedia = $medicalMediaPath !== '' && (preg_match('/^https?:\/\//i', $medicalMediaPath) || substr($medicalMediaPath, 0, 1) === '/'); +$medicalGalleryModalId = 'edit-medical-media-gallery-modal'; +$medicalFormId = 'edit-medical-visit-form'; + +$this->registerCss(<< .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-editor-shell > .panel-body { + background: rgba(10, 18, 28, 0.2); +} + +.inline-editor-shell .panel.panel-default { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(10, 18, 28, 0.34); +} + +.inline-editor-shell .panel.panel-default > .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-editor-shell, +.inline-editor-shell .panel-body, +.inline-editor-shell .control-label, +.inline-editor-shell .checkbox label, +.inline-editor-shell .radio label, +.inline-editor-shell .help-block { + color: #eef5fb; +} + +.inline-editor-shell .text-muted { + color: rgba(233, 242, 250, 0.78) !important; +} + +.inline-editor-shell .form-control { + background: rgba(10, 18, 28, 0.56); + border-color: rgba(255, 255, 255, 0.44); + color: #f3f8ff; +} + +.inline-editor-shell .form-control::placeholder { + color: rgba(243, 248, 255, 0.72); +} + +.inline-editor-shell .form-control[readonly], +.inline-editor-shell .form-control[disabled] { + background: rgba(10, 18, 28, 0.42); + color: rgba(243, 248, 255, 0.72); +} + +.inline-editor-shell select.form-control option { + color: #0f1b2a; +} +CSS +); + +if ($isInline) { + $this->registerCss(<< .panel:first-child { + margin-top: 0 !important; +} +CSS + ); +} +?> + + + +
+
+
+ Edit Medical Visit') ?> + + + ', [ + 'class' => 'btn btn-default btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Save Medical Visit'), + 'form' => $medicalFormId, + ]) ?> + ', [ + 'type' => 'button', + 'class' => 'btn btn-default btn-sm', + 'id' => 'medical-inline-cancel-icon', + 'title' => Yii::t('AnimalManagementModule.base', 'Cancel'), + ]) ?> + + +
+
+
+ $medicalFormId, 'enctype' => 'multipart/form-data']; + if (!$isInline) { + $formOptions['target'] = '_top'; + } + $form = ActiveForm::begin(['options' => $formOptions]); + ?> + + 'medical-media-gallery-path']) ?> + + errorSummary($model, ['showAllErrors' => true]) ?> + +
+
+
+
+
field($model, 'visit_at')->input('datetime-local') ?>
+
field($model, 'provider_name') ?>
+
+ field($model, 'notes')->textarea(['rows' => 3]) ?> + field($model, 'recommendations')->textarea(['rows' => 3]) ?> +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + <?= Yii::t('AnimalManagementModule.base', 'Selected medical media') ?> + + + +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ field($model, 'post_to_space_feed')->checkbox() ?> + field($model, 'post_to_animal_feed')->checkbox() ?> +
+
+ + +
+
+
+ $definition): ?> + + +
+
+ + + submit() ?> + + 'button', + 'class' => 'btn btn-default', + 'id' => 'medical-inline-cancel', + ]) ?> + + link(($returnTo ?? 'view') === 'medical-visits' + ? $space->createUrl('/animal_management/animals/medical-visits', ['id' => $animal->id]) + : $space->createUrl('/animal_management/animals/view', ['id' => $animal->id])) ?> + + + +
+
+ + + +registerJs(<<'); + } else { + preview.html(''); + } + } + + function markSelectedMedicalThumb(value) { + $('.medical-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.medical-media-select-thumb').each(function() { + if ($(this).data('media-url') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).on('click', '.medical-media-select-thumb', function() { + var mediaUrl = $(this).data('media-url'); + $('#medical-media-gallery-path').val(mediaUrl); + markSelectedMedicalThumb(mediaUrl); + $('#medicalMediaUpload').val(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + if (mediaUrl) { + renderMedicalPreview(mediaUrl); + } + $('#{$medicalGalleryModalId}').modal('hide'); + }); + + $('#medicalMediaUpload').on('change', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#medical-media-gallery-path').val(''); + markSelectedMedicalThumb(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + renderMedicalPreview(e.target.result); + $('#{$medicalGalleryModalId}').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $('#{$medicalGalleryModalId}').on('shown.bs.modal', function() { + markSelectedMedicalThumb($('#medical-media-gallery-path').val()); + }); +})(); +JS +, \yii\web\View::POS_END); + +if ($isInline) { + $cancelPayload = Json::htmlEncode([ + 'source' => 'animal-inline-editor', + 'type' => 'cancel', + 'collapseId' => 'medical-edit-inline-' . (int)$medicalVisit->id, + ]); + + $this->registerJs(<< diff --git a/views/animals/edit-progress-update.php b/views/animals/edit-progress-update.php new file mode 100644 index 0000000..87e2eaa --- /dev/null +++ b/views/animals/edit-progress-update.php @@ -0,0 +1,424 @@ +customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalprogressupdateform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownProgressKeys = ['progress_notes', 'routine_updates', 'media_reference']; +$otherCustomDefinitions = []; +foreach ($customDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownProgressKeys, true)) { + continue; + } + $otherCustomDefinitions[$fieldKey] = $definition; +} + +$currentMediaReference = trim((string)($model->customFields['media_reference'] ?? '')); +$progressFormId = 'edit-progress-update-form'; + +$this->registerCss(<< .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-editor-shell > .panel-body { + background: rgba(10, 18, 28, 0.2); +} + +.inline-editor-shell .panel.panel-default { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(10, 18, 28, 0.34); +} + +.inline-editor-shell .panel.panel-default > .panel-heading { + color: #eef5fb; + background: rgba(10, 18, 28, 0.42); + border-color: rgba(255, 255, 255, 0.2); +} + +.inline-editor-shell, +.inline-editor-shell .panel-body, +.inline-editor-shell .control-label, +.inline-editor-shell .checkbox label, +.inline-editor-shell .radio label, +.inline-editor-shell .help-block { + color: #eef5fb; +} + +.inline-editor-shell .text-muted { + color: rgba(233, 242, 250, 0.78) !important; +} + +.inline-editor-shell .form-control { + background: rgba(10, 18, 28, 0.56); + border-color: rgba(255, 255, 255, 0.44); + color: #f3f8ff; +} + +.inline-editor-shell .form-control::placeholder { + color: rgba(243, 248, 255, 0.72); +} + +.inline-editor-shell .form-control[readonly], +.inline-editor-shell .form-control[disabled] { + background: rgba(10, 18, 28, 0.42); + color: rgba(243, 248, 255, 0.72); +} + +.inline-editor-shell select.form-control option { + color: #0f1b2a; +} +CSS +); + +if ($isInline) { + $this->registerCss(<< .panel:first-child { + margin-top: 0 !important; +} +CSS + ); +} +?> + +
+
+
+ Edit Progress Update') ?> + + + ', [ + 'class' => 'btn btn-default btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Save Progress Update'), + 'form' => $progressFormId, + ]) ?> + ', [ + 'type' => 'button', + 'class' => 'btn btn-default btn-sm', + 'id' => 'progress-inline-cancel-icon', + 'title' => Yii::t('AnimalManagementModule.base', 'Cancel'), + ]) ?> + + +
+
+
+ $progressFormId, 'enctype' => 'multipart/form-data']; + if (!$isInline) { + $formOptions['target'] = '_top'; + } + $form = ActiveForm::begin(['options' => $formOptions]); + ?> + + + errorSummary($model, ['showAllErrors' => true]) ?> + +
+
+
+
+
field($model, 'weight') ?>
+
field($model, 'vitals')->textInput(['maxlength' => 255]) ?>
+
field($model, 'behavior_notes')->textarea(['rows' => 2]) ?>
+
field($model, 'medical_concerns')->textarea(['rows' => 2]) ?>
+
field($model, 'meal_plan_changes')->textarea(['rows' => 2]) ?>
+
field($model, 'housing_changes')->textarea(['rows' => 2]) ?>
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ $definition): ?> + + +
+
+ + +
+
+
+ +
+
+
+ + <?= Yii::t('AnimalManagementModule.base', 'Selected media') ?> + + + +
+
+
+ + +
+ +
+
+
+
+
+ +
+
+
+ field($model, 'post_to_space_feed')->checkbox() ?> + field($model, 'post_to_animal_feed')->checkbox() ?> +
+
+ + + + submit() ?> + + 'button', + 'class' => 'btn btn-default', + 'id' => 'progress-inline-cancel', + ]) ?> + + link(($returnTo ?? 'view') === 'progress-updates' + ? $space->createUrl('/animal_management/animals/progress-updates', ['id' => $animal->id]) + : $space->createUrl('/animal_management/animals/view', ['id' => $animal->id])) ?> + + + +
+
+ +registerCss(<<registerJs(<<'); + } else { + preview.html(''); + } + } + + function markSelectedMediaThumb(value) { + $('.progress-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.progress-media-select-thumb').each(function() { + if (($(this).attr('data-media-url') || '') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).off('click.editProgressMediaSelect', '.progress-media-select-thumb').on('click.editProgressMediaSelect', '.progress-media-select-thumb', function() { + var mediaUrl = $(this).attr('data-media-url') || ''; + $('#progress-media-gallery-path').val(mediaUrl); + markSelectedMediaThumb(mediaUrl); + $('#progressMediaUpload').val(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + if (mediaUrl) { + renderProgressPreview(mediaUrl); + } + $('#progress-media-modal').modal('hide'); + }); + + $(document).off('change.editProgressMediaUpload', '#progressMediaUpload').on('change.editProgressMediaUpload', '#progressMediaUpload', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#progress-media-gallery-path').val(''); + markSelectedMediaThumb(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + renderProgressPreview(e.target.result); + $('#progress-media-modal').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $(document).off('shown.bs.modal.editProgressMediaModal', '#progress-media-modal').on('shown.bs.modal.editProgressMediaModal', '#progress-media-modal', function() { + markSelectedMediaThumb($('#progress-media-gallery-path').val()); + }); +})(); +JS +, \yii\web\View::POS_END); + +if ($isInline) { + $cancelPayload = Json::htmlEncode([ + 'source' => 'animal-inline-editor', + 'type' => 'cancel', + 'collapseId' => 'progress-edit-inline-' . (int)$progressUpdate->id, + ]); + + $this->registerJs(<< diff --git a/views/animals/index.php b/views/animals/index.php new file mode 100644 index 0000000..83e3d9f --- /dev/null +++ b/views/animals/index.php @@ -0,0 +1,283 @@ + $latestMedicalVisitByAnimal */ +/* @var array $animalImageUrls */ +/* @var array $transferAnimalImageUrls */ +/* @var array $tileFields */ +/* @var array $tileFieldOverrides */ +/* @var Space $space */ +/* @var bool $canManage */ + +$currentParams = [ + 'q' => $queryValue, + 'status' => $statusFilter, + 'species' => $speciesFilter, + 'view' => $viewMode, + 'sort' => $sortKey, + 'direction' => $sortDirection, + 'cols' => $selectedColumns, +]; + +$buildUrl = static function (array $overrides) use ($space, $currentParams): string { + $params = array_merge($currentParams, $overrides); + return $space->createUrl('/animal_management/animals/index', $params); +}; + +$sortUrl = static function (string $column) use ($buildUrl, $sortKey, $sortDirection): string { + $nextDirection = ($sortKey === $column && $sortDirection === 'asc') ? 'desc' : 'asc'; + return $buildUrl(['sort' => $column, 'direction' => $nextDirection, 'view' => 'table']); +}; +?> + +
+
+ Animals') ?> + + ', $buildUrl(['view' => 'tiles']), [ + 'class' => 'btn btn-default btn-sm' . ($viewMode === 'tiles' ? ' active' : ''), + 'title' => Yii::t('AnimalManagementModule.base', 'Tile View'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Tile View'), + ]) ?> + ', $buildUrl(['view' => 'table']), [ + 'class' => 'btn btn-default btn-sm' . ($viewMode === 'table' ? ' active' : ''), + 'title' => Yii::t('AnimalManagementModule.base', 'Table View'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Table View'), + ]) ?> + + ' . Yii::t('AnimalManagementModule.base', 'Intake'), $space->createUrl('/animal_management/animals/create'), [ + 'class' => 'btn btn-primary btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Intake'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Intake'), + ]) ?> + + +
+ +
+
+ + + + + + + +
+ +
+
+ Yii::t('AnimalManagementModule.base', 'All Statuses')] + Animal::statusOptions(), ['class' => 'form-control']) ?> +
+
+ Yii::t('AnimalManagementModule.base', 'All Species')]; ?> + + + + 'form-control']) ?> +
+ + link($space->createUrl('/animal_management/animals/index', ['view' => $viewMode])) ?> +
+ + +
+ +
+ + + + + + + $columnLabel): ?> + + + +
+
+ + + +
+ +
+ + +
+ + id; ?> + +
+ render('_tile', [ + 'animal' => $animal, + 'contentContainer' => $space, + 'lastMedical' => $lastMedical, + 'imageUrl' => $animalImageUrls[$animalId] ?? '', + 'tileFields' => $tileFieldOverrides[$animalId] ?? $tileFields, + 'showMedicalIcon' => true, + ]) ?> +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + id] ?? null; ?> + + + + + + + + + + + + + + + + + + + + + +
animal_uid) ?> + + getDisplayName()) ?> + + species) ?>status] ?? $animal->status) ?> + + + visit_at)) ?> + + + - + + updated_at)) ?> + link($space->createUrl('/animal_management/animals/view', ['id' => $animal->id])) ?> + link($space->createUrl('/animal_management/animals/medical-visits', ['id' => $animal->id])) ?> + link($space->createUrl('/animal_management/animals/progress-updates', ['id' => $animal->id])) ?> + + link($space->createUrl('/animal_management/animals/edit', ['id' => $animal->id])) ?> + link($space->createUrl('/animal_management/animals/transfer', ['id' => $animal->id])) ?> + +
+
+ + + + +
+ +

+ +
+ +
+ + getFromSpace(); + $toSpace = $transfer->getToSpace(); + $animalLinkSpace = ($transfer->status === AnimalTransfer::STATUS_COMPLETED) + ? ($toSpace ?: $fromSpace) + : ($fromSpace ?: $toSpace); + ?> +
+ render('_transfer_tile', [ + 'transfer' => $transfer, + 'space' => $space, + 'otherRescueName' => $fromSpace ? $fromSpace->name : Yii::t('AnimalManagementModule.base', 'Unknown Rescue'), + 'otherRescueUrl' => $fromSpace ? $fromSpace->createUrl('/space/space/home') : '', + 'animalProfileUrl' => $animalLinkSpace ? $animalLinkSpace->createUrl('/animal_management/animals/view', ['id' => $transfer->animal_id]) : '', + 'imageUrl' => trim((string)($transferAnimalImageUrls[(int)$transfer->animal_id] ?? '')), + 'isIncoming' => true, + ]) ?> +
+ +
+ + +

+ +
+ +
+ + getFromSpace(); + $toSpace = $transfer->getToSpace(); + $animalLinkSpace = ($transfer->status === AnimalTransfer::STATUS_COMPLETED) + ? ($toSpace ?: $fromSpace) + : ($fromSpace ?: $toSpace); + ?> +
+ render('_transfer_tile', [ + 'transfer' => $transfer, + 'space' => $space, + 'otherRescueName' => $toSpace ? $toSpace->name : Yii::t('AnimalManagementModule.base', 'Unknown Rescue'), + 'otherRescueUrl' => $toSpace ? $toSpace->createUrl('/space/space/home') : '', + 'animalProfileUrl' => $animalLinkSpace ? $animalLinkSpace->createUrl('/animal_management/animals/view', ['id' => $transfer->animal_id]) : '', + 'imageUrl' => trim((string)($transferAnimalImageUrls[(int)$transfer->animal_id] ?? '')), + 'isIncoming' => false, + ]) ?> +
+ +
+ + +
+
diff --git a/views/animals/inline-edit-result.php b/views/animals/inline-edit-result.php new file mode 100644 index 0000000..613f164 --- /dev/null +++ b/views/animals/inline-edit-result.php @@ -0,0 +1,34 @@ + 'animal-inline-editor', + 'type' => 'saved', + 'collapseId' => (string)$collapseId, + 'refreshSelectors' => array_values(array_map('strval', $refreshSelectors ?? [])), +]; + +$jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +?> + +
+
+ +
+
+ +registerJs(<< diff --git a/views/animals/medical-visits.php b/views/animals/medical-visits.php new file mode 100644 index 0000000..b7656c0 --- /dev/null +++ b/views/animals/medical-visits.php @@ -0,0 +1,901 @@ + 'BP', + 'oxygen' => 'O₂', + ]; + $label = (string)($vitalLabelOverrides[$fieldKey] ?? $definition['label']); + if ((int)$definition['required'] === 1) { + $label .= ' *'; + } + + $fieldName = "AnimalMedicalVisitForm[customFields][$fieldKey]"; + $fieldValue = $model->customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalmedicalvisitform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalmedicalvisitform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownMedicalKeys = [ + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'medical_media_reference', + 'media_reference', +]; + +$remainingMedicalDefinitions = []; +foreach ($medicalCustomDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownMedicalKeys, true) || in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + $remainingMedicalDefinitions[$fieldKey] = $definition; +} + +$newMedicalMediaPath = trim((string)($medicalVisitForm->customFields['medical_media_reference'] ?? $medicalVisitForm->customFields['media_reference'] ?? '')); +$hasNewMedicalMedia = $newMedicalMediaPath !== '' && (preg_match('/^https?:\/\//i', $newMedicalMediaPath) || substr($newMedicalMediaPath, 0, 1) === '/'); +$medicalGalleryModalId = 'medical-media-gallery-modal'; +$medicalAddModalId = 'add-medical-visit-modal'; +$medicalFormId = 'add-medical-visit-form'; +$openMedicalEditId = (int)Yii::$app->request->get('inlineMedicalEdit', 0); +$openMedicalAdd = (int)Yii::$app->request->get('inlineMedicalAdd', 0) === 1; +$medicalVitalLabelOverrides = [ + 'blood_pressure' => 'BP', + 'oxygen' => 'O₂', +]; +?> + + + +
+
+ Medical Visits') ?> +
+
+
+ getDisplayName()), + $space->createUrl('/animal_management/animals/view', ['id' => $animal->id]), + ['style' => 'font-size:20px;font-weight:700;line-height:1.2;'] + ) ?> + createUrl('/animal_management/animals/index'), + ['style' => 'font-size:16px;line-height:1.2;'] + ) ?> + + ' . Yii::t('AnimalManagementModule.base', 'Add Medical Visit'), '#medical-add-inline', [ + 'class' => 'btn btn-success btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Add Medical Visit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Add Medical Visit'), + 'data-toggle' => 'collapse', + ]) ?> + +
+ + +
+ +
+ + + +
+ + + getCustomFieldDisplayValues($canManage); + $visitFieldsByKey = []; + $additionalVisitFields = []; + $medicalMedia = ''; + foreach ($visitCustomValues as $customField) { + $fieldKey = (string)($customField['field_key'] ?? ''); + if (in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + + $fieldValue = trim((string)($customField['value'] ?? '')); + if ($fieldValue === '') { + continue; + } + + if ($fieldKey === 'medical_media_reference' || $fieldKey === 'media_reference') { + $medicalMedia = $fieldValue; + continue; + } + + if (in_array($fieldKey, $knownMedicalKeys, true)) { + $visitFieldsByKey[$fieldKey] = [ + 'label' => (string)($medicalVitalLabelOverrides[$fieldKey] ?? ($customField['label'] ?? $fieldKey)), + 'value' => $fieldValue, + ]; + continue; + } + + $additionalVisitFields[] = [ + 'label' => (string)($customField['label'] ?? $fieldKey), + 'value' => $fieldValue, + ]; + } + + $hasMedicalMedia = $medicalMedia !== '' && (preg_match('/^https?:\/\//i', $medicalMedia) || substr($medicalMedia, 0, 1) === '/'); + $visitDateDisplay = DateDisplayHelper::format((string)$visit->visit_at); + $vitalKeys = ['weight', 'pulse', 'blood_pressure', 'oxygen']; + $hasVitals = false; + foreach ($vitalKeys as $vitalKey) { + if (!empty($visitFieldsByKey[$vitalKey]['value'])) { + $hasVitals = true; + break; + } + } + ?> +
+ + <?= Yii::t('AnimalManagementModule.base', 'Medical media') ?> + +
+
+
+ visit_at) ?> + +
+ + + + : + + +
+ +
+
+
+
+ provider_name)): ?> +
: provider_name) ?>
+ +
+ + ', + '#medical-edit-inline-' . (int)$visit->id, + [ + 'class' => 'btn btn-xs btn-default', + 'data-toggle' => 'collapse', + 'title' => Yii::t('AnimalManagementModule.base', 'Edit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Edit'), + ] + ) ?> + +
+ + notes)): ?> + +
notes)) ?>
+ + + recommendations)): ?> + +
recommendations)) ?>
+ + + + +
+ + + + + + +
+ +
+ +
+ + + + +
+ + + + +
+ +
:
+ +
+ +
+
+ + +
+ +
+ +
+
+ + + + + + +
+ + + + + + registerJs(<< 0)) { + sideSpacing = parseFloat(editorNode.closest('.panel-body').css('padding-left')); + } + if (!(sideSpacing > 0)) { + sideSpacing = 14; + } + + var fixedHeaderHeight = 0; + $('.navbar-fixed-top:visible, #topbar:visible, .topbar:visible, .layout-top-container:visible').each(function() { + var h = $(this).outerHeight() || 0; + if (h > fixedHeaderHeight) { + fixedHeaderHeight = h; + } + }); + + var topReserve = Math.max(sideSpacing, 14) + Math.max(fixedHeaderHeight, 64) + 28; + var top = Math.max(0, editorNode.offset().top - topReserve); + $('html, body').stop(true).animate({scrollTop: top}, 220); + } + + function refreshMedicalVisitsPageRoot() { + return $.get(window.location.href).done(function(html) { + var doc = $('
').append($.parseHTML(html, document, true)); + var nextRoot = doc.find(pageRootSelector).first(); + if (!nextRoot.length) { + return; + } + + $(pageRootSelector).replaceWith(nextRoot); + if (typeof window.initMedicalVisitsPage === 'function') { + window.initMedicalVisitsPage(); + } + }); + } + + if (!window.__animalMedicalVisitsInlineListenerBound) { + window.__animalMedicalVisitsInlineListenerBound = true; + window.addEventListener('message', function(event) { + var data = event.data || {}; + if (!data || typeof data !== 'object' || data.source !== 'animal-inline-editor') { + return; + } + + if (data.type === 'cancel') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + return; + } + + if (data.type === 'saved') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + refreshMedicalVisitsPageRoot(); + } + }); + } + + window.initMedicalVisitsPage = function() { + $(document) + .off('shown.bs.collapse.medicalInlineScroll', pageRootSelector + ' .medical-feed-inline-editor') + .on('shown.bs.collapse.medicalInlineScroll', pageRootSelector + ' .medical-feed-inline-editor', function() { + scrollInlineEditorIntoView(this); + }); + + $(document) + .off('click.medicalInlineScroll', pageRootSelector + ' a[href^="#medical-edit-inline-"], ' + pageRootSelector + ' a[href="#medical-add-inline"]') + .on('click.medicalInlineScroll', pageRootSelector + ' a[href^="#medical-edit-inline-"], ' + pageRootSelector + ' a[href="#medical-add-inline"]', function() { + var target = $(this).attr('href'); + if (!target || target.charAt(0) !== '#') { + return; + } + + window.setTimeout(function() { + scrollInlineEditorIntoView($(target)); + }, 260); + }); + + var preopenedEditor = $(pageRootSelector + ' .medical-feed-inline-editor.in').first(); + if (preopenedEditor.length) { + window.setTimeout(function() { + scrollInlineEditorIntoView(preopenedEditor); + }, 260); + } + + function renderMedicalPreview(source) { + var preview = $('#medical-media-preview'); + if (!preview.length) { + return; + } + + if (source) { + preview.html('Selected medical media'); + } else { + preview.html(''); + } + } + + function markSelectedMedicalThumb(value) { + $('.medical-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.medical-media-select-thumb').each(function() { + if (($(this).attr('data-media-url') || '') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).off('click.medicalMediaSelect', pageRootSelector + ' .medical-media-select-thumb').on('click.medicalMediaSelect', pageRootSelector + ' .medical-media-select-thumb', function() { + var mediaUrl = $(this).attr('data-media-url') || ''; + $('#medical-media-gallery-path').val(mediaUrl); + markSelectedMedicalThumb(mediaUrl); + $('#medicalMediaUpload').val(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + if (mediaUrl) { + renderMedicalPreview(mediaUrl); + } + $('#{$medicalGalleryModalId}').modal('hide'); + }); + + $(document).off('change.medicalMediaUpload', '#medicalMediaUpload').on('change.medicalMediaUpload', '#medicalMediaUpload', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#medical-media-gallery-path').val(''); + markSelectedMedicalThumb(''); + $('input[name="removeMedicalMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + renderMedicalPreview(e.target.result); + $('#{$medicalGalleryModalId}').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $(document).off('shown.bs.modal.medicalMediaModal', '#{$medicalGalleryModalId}').on('shown.bs.modal.medicalMediaModal', '#{$medicalGalleryModalId}', function() { + markSelectedMedicalThumb($('#medical-media-gallery-path').val()); + }); + + $(document).off('submit.medicalVisitAjax', formSelector).on('submit.medicalVisitAjax', formSelector, function(event) { + event.preventDefault(); + + var form = this; + var formData = new FormData(form); + var submitButtons = $(form).find('button[type="submit"], input[type="submit"]'); + submitButtons.prop('disabled', true); + + $.ajax({ + url: form.action, + type: 'POST', + data: formData, + processData: false, + contentType: false + }).always(function() { + submitButtons.prop('disabled', false); + }).done(function() { + $('#{$medicalAddModalId}').modal('hide'); + refreshMedicalVisitsPageRoot(); + }); + }); + }; + + window.initMedicalVisitsPage(); +})(); +JS + , \yii\web\View::POS_END); + ?> + diff --git a/views/animals/progress-updates.php b/views/animals/progress-updates.php new file mode 100644 index 0000000..937ee5a --- /dev/null +++ b/views/animals/progress-updates.php @@ -0,0 +1,694 @@ +customFields[$fieldKey] ?? ''; + + ob_start(); + ?> + +
+ + 'form-control', 'rows' => 3, 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ +
+ +
+ + getCustomFieldSelectOptions($fieldKey), + ['class' => 'form-control', 'prompt' => Yii::t('AnimalManagementModule.base', 'Select...'), 'id' => "animalprogressupdateform-customfields-$fieldKey"] + ) ?> +
+ +
+ + 'form-control', 'step' => 'any', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ +
+ + 'form-control', 'id' => "animalprogressupdateform-customfields-$fieldKey"]) ?> +
+ + getCustomFieldDefinitions(); +$knownProgressKeys = ['progress_notes', 'routine_updates', 'media_reference']; +$otherProgressCustomDefinitions = []; +foreach ($progressCustomDefinitions as $fieldKey => $definition) { + if (in_array($fieldKey, $knownProgressKeys, true)) { + continue; + } + $otherProgressCustomDefinitions[$fieldKey] = $definition; +} + +$currentMediaReference = trim((string)($progressUpdateForm->customFields['media_reference'] ?? '')); +$progressAddModalId = 'add-progress-update-modal'; +$progressFormId = 'add-progress-update-main-form'; +$openProgressEditId = (int)Yii::$app->request->get('inlineProgressEdit', 0); +$openProgressAdd = (int)Yii::$app->request->get('inlineProgressAdd', 0) === 1; +?> + + + +
+
+ Progress Feed') ?> +
+
+
+ getDisplayName()), + $space->createUrl('/animal_management/animals/view', ['id' => $animal->id]), + ['style' => 'font-size:20px;font-weight:700;line-height:1.2;'] + ) ?> + createUrl('/animal_management/animals/index'), + ['style' => 'font-size:16px;line-height:1.2;'] + ) ?> + + ' . Yii::t('AnimalManagementModule.base', 'Add Progress Update'), '#progress-add-inline', [ + 'class' => 'btn btn-success btn-sm', + 'title' => Yii::t('AnimalManagementModule.base', 'Add Progress Update'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Add Progress Update'), + 'data-toggle' => 'collapse', + ]) ?> + +
+ + +
+ +
+ + + +
+ + + getCustomFieldDisplayValues($canManage); + $mediaReference = ''; + $progressCustomDisplayValues = []; + foreach ($progressCustomValues as $customField) { + if ((string)($customField['field_key'] ?? '') === 'media_reference') { + $mediaReference = trim((string)$customField['value']); + continue; + } + + $progressCustomDisplayValues[] = $customField; + } + + $hasMediaImage = $mediaReference !== '' && (preg_match('/^https?:\/\//i', $mediaReference) || substr($mediaReference, 0, 1) === '/'); + ?> +
+ + <?= Yii::t('AnimalManagementModule.base', 'Progress media') ?> + +
+
+
+ update_at)) ?> + weight) || !empty($update->vitals)): ?> +
+ weight)): ?>: weight) ?> + vitals)): ?> +
+ +
+ +
+
+ + ', + '#progress-edit-inline-' . (int)$update->id, + [ + 'class' => 'btn btn-xs btn-default', + 'data-toggle' => 'collapse', + 'title' => Yii::t('AnimalManagementModule.base', 'Edit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Edit'), + ] + ) ?> + +
+ + vitals)): ?>
vitals)) ?>
+ behavior_notes)): ?>
behavior_notes)) ?>
+ meal_plan_changes)): ?>
meal_plan_changes)) ?>
+ housing_changes)): ?>
housing_changes)) ?>
+ medical_concerns)): ?>
medical_concerns)) ?>
+ + +
+
+ +
:
+ +
+ +
+ + +
+ +
+ +
+
+ + + + + + + + +
+
+ +registerCss(<<registerJs(<< 0)) { + sideSpacing = parseFloat(editorNode.closest('.panel-body').css('padding-left')); + } + if (!(sideSpacing > 0)) { + sideSpacing = 14; + } + + var fixedHeaderHeight = 0; + $('.navbar-fixed-top:visible, #topbar:visible, .topbar:visible, .layout-top-container:visible').each(function() { + var h = $(this).outerHeight() || 0; + if (h > fixedHeaderHeight) { + fixedHeaderHeight = h; + } + }); + + var topReserve = Math.max(sideSpacing, 14) + Math.max(fixedHeaderHeight, 64) + 28; + var top = Math.max(0, editorNode.offset().top - topReserve); + $('html, body').stop(true).animate({scrollTop: top}, 220); + } + + function refreshProgressUpdatesPageRoot() { + return $.get(window.location.href).done(function(html) { + var doc = $('
').append($.parseHTML(html, document, true)); + var nextRoot = doc.find(pageRootSelector).first(); + if (!nextRoot.length) { + return; + } + + $(pageRootSelector).replaceWith(nextRoot); + if (typeof window.initProgressUpdatesPage === 'function') { + window.initProgressUpdatesPage(); + } + }); + } + + if (!window.__animalProgressUpdatesInlineListenerBound) { + window.__animalProgressUpdatesInlineListenerBound = true; + window.addEventListener('message', function(event) { + var data = event.data || {}; + if (!data || typeof data !== 'object' || data.source !== 'animal-inline-editor') { + return; + } + + if (data.type === 'cancel') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + return; + } + + if (data.type === 'saved') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + refreshProgressUpdatesPageRoot(); + } + }); + } + + window.initProgressUpdatesPage = function() { + $(document) + .off('shown.bs.collapse.progressInlineScroll', pageRootSelector + ' .progress-feed-inline-editor') + .on('shown.bs.collapse.progressInlineScroll', pageRootSelector + ' .progress-feed-inline-editor', function() { + scrollInlineEditorIntoView(this); + }); + + $(document) + .off('click.progressInlineScroll', pageRootSelector + ' a[href^="#progress-edit-inline-"], ' + pageRootSelector + ' a[href="#progress-add-inline"]') + .on('click.progressInlineScroll', pageRootSelector + ' a[href^="#progress-edit-inline-"], ' + pageRootSelector + ' a[href="#progress-add-inline"]', function() { + var target = $(this).attr('href'); + if (!target || target.charAt(0) !== '#') { + return; + } + + window.setTimeout(function() { + scrollInlineEditorIntoView($(target)); + }, 260); + }); + + var preopenedEditor = $(pageRootSelector + ' .progress-feed-inline-editor.in').first(); + if (preopenedEditor.length) { + window.setTimeout(function() { + scrollInlineEditorIntoView(preopenedEditor); + }, 260); + } + + function markSelectedMediaThumb(value) { + $('.progress-media-select-thumb').removeClass('is-selected'); + if (!value) { + return; + } + + $('.progress-media-select-thumb').each(function() { + if (($(this).attr('data-media-url') || '') === value) { + $(this).addClass('is-selected'); + } + }); + } + + $(document).off('click.progressMediaSelect', '.progress-media-select-thumb').on('click.progressMediaSelect', '.progress-media-select-thumb', function(event) { + event.preventDefault(); + var mediaUrl = $(this).attr('data-media-url') || ''; + $('#progress-media-gallery-path').val(mediaUrl); + markSelectedMediaThumb(mediaUrl); + $('#progressMediaUpload').val(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + if (mediaUrl) { + $('#progress-media-preview').html('Selected media'); + } + $('#progress-media-modal').modal('hide'); + }); + + $(document).off('change.progressMediaUpload', '#progressMediaUpload').on('change.progressMediaUpload', '#progressMediaUpload', function() { + var file = this.files && this.files[0] ? this.files[0] : null; + if (!file) { + return; + } + + $('#progress-media-gallery-path').val(''); + markSelectedMediaThumb(''); + $('input[name="removeProgressMedia"]').prop('checked', false); + + var reader = new FileReader(); + reader.onload = function(e) { + $('#progress-media-preview').html('Selected media'); + $('#progress-media-modal').modal('hide'); + }; + reader.readAsDataURL(file); + }); + + $(document).off('shown.bs.modal.progressMediaModal', '#progress-media-modal').on('shown.bs.modal.progressMediaModal', '#progress-media-modal', function() { + markSelectedMediaThumb($('#progress-media-gallery-path').val()); + }); + + $(document).off('show.bs.modal.progressMediaStack', '#progress-media-modal').on('show.bs.modal.progressMediaStack', '#progress-media-modal', function() { + var zIndex = 1060 + (10 * $('.modal.in:visible').length); + $(this).css('z-index', zIndex); + + window.setTimeout(function() { + $('.modal-backdrop').not('.progress-media-stack').last().css('z-index', zIndex - 1).addClass('progress-media-stack'); + }, 0); + }); + + $(document).off('hidden.bs.modal.progressMediaStack', '#progress-media-modal').on('hidden.bs.modal.progressMediaStack', '#progress-media-modal', function() { + $(this).css('z-index', ''); + if ($('#{$progressAddModalId}').is(':visible')) { + $('body').addClass('modal-open'); + } + }); + + $(document).off('submit.progressAjax', formSelector).on('submit.progressAjax', formSelector, function(event) { + event.preventDefault(); + + var form = this; + var formData = new FormData(form); + var submitButtons = $(form).find('button[type="submit"], input[type="submit"]'); + submitButtons.prop('disabled', true); + + $.ajax({ + url: form.action, + type: 'POST', + data: formData, + processData: false, + contentType: false + }).always(function() { + submitButtons.prop('disabled', false); + }).done(function() { + $('#{$progressAddModalId}').modal('hide'); + refreshProgressUpdatesPageRoot(); + }); + }); + }; + + window.initProgressUpdatesPage(); +})(); +JS +); +?> diff --git a/views/animals/transfer.php b/views/animals/transfer.php new file mode 100644 index 0000000..174f7cf --- /dev/null +++ b/views/animals/transfer.php @@ -0,0 +1,31 @@ + + +
+
+ Transfer Request for {animal}', ['animal' => $animal->getDisplayName()]) ?> +
+
+ + + field($model, 'to_space_id')->dropDownList($model->getTargetOptions(), ['prompt' => Yii::t('AnimalManagementModule.base', 'Select destination rescue')]) ?> + field($model, 'request_message')->textarea(['rows' => 4]) ?> + field($model, 'conditions_text')->textarea(['rows' => 3]) ?> + + submit() ?> + link($space->createUrl('/animal_management/animals/index')) ?> + + +
+
diff --git a/views/animals/view.php b/views/animals/view.php new file mode 100644 index 0000000..0ba89a6 --- /dev/null +++ b/views/animals/view.php @@ -0,0 +1,948 @@ +request->get('inlineMedicalEdit', 0); +$openProgressEditId = (int)Yii::$app->request->get('inlineProgressEdit', 0); + +$coverImageUrl = trim((string)$animalCoverImageUrl); +$hasCoverImage = $coverImageUrl !== '' && (preg_match('/^https?:\/\//i', $coverImageUrl) || substr($coverImageUrl, 0, 1) === '/'); +$statusLabel = Animal::statusOptions()[$animal->status] ?? (string)$animal->status; + +$detailFieldMap = [ + 'name' => (string)$animal->getDisplayName(), + 'species' => (string)$animal->species, + 'breed' => (string)$animal->breed, + 'sex' => (string)$animal->sex, + 'status' => (string)$statusLabel, + 'location_name' => (string)$animal->location_name, + 'animal_uid' => (string)$animal->animal_uid, + 'public_summary' => trim((string)$animal->public_summary), + 'last_medical' => !empty($medicalVisits) ? DateDisplayHelper::format((string)$medicalVisits[0]->visit_at) : '', +]; + +$heroFieldValues = []; +foreach ($detailHeroFields as $fieldKey) { + $fieldKey = trim((string)$fieldKey); + if ($fieldKey === '' || $fieldKey === 'name' || !array_key_exists($fieldKey, $detailFieldMap)) { + continue; + } + + $value = trim((string)$detailFieldMap[$fieldKey]); + if ($value === '') { + continue; + } + + $heroFieldValues[] = $value; +} + +$customHeroCount = 0; +foreach ($customFieldValues as $customField) { + if ($customHeroCount >= 3) { + break; + } + + $label = trim((string)($customField['label'] ?? '')); + $value = trim((string)($customField['value'] ?? '')); + if ($label === '' || $value === '') { + continue; + } + + $heroFieldValues[] = $label . ': ' . $value; + $customHeroCount++; +} + +if (class_exists(GalleryAssets::class)) { + GalleryAssets::register($this); +} + +$uiGalleryId = 'animal-gallery-' . (int)$animal->id; +?> + +
+
+ + <?= Html::encode($animal->getDisplayName()) ?> + +
+ +
+ +
+ + +
+ + + +
+ + +
+
+ getDisplayName()) ?> +
+ +
+ + + + + +
+ + + public_summary)): ?> +
+ public_summary)) ?> +
+ +
+
+
+ +registerJs(<<<'JS' +(function() { + function getCsrfPayload() { + var csrfParam = $('meta[name="csrf-param"]').attr('content') || ''; + var csrfToken = $('meta[name="csrf-token"]').attr('content') || ''; + var payload = {}; + if (csrfParam && csrfToken) { + payload[csrfParam] = csrfToken; + } + return payload; + } + + function scrollInlineEditorIntoView(editor) { + var $editor = $(editor); + if (!$editor.length) { + return; + } + + var sideSpacing = parseFloat($editor.css('margin-left')); + if (!(sideSpacing > 0)) { + sideSpacing = parseFloat($editor.closest('.panel-body').css('padding-left')); + } + if (!(sideSpacing > 0)) { + sideSpacing = 14; + } + + var fixedHeaderHeight = 0; + $('.navbar-fixed-top:visible, #topbar:visible, .topbar:visible, .layout-top-container:visible').each(function() { + var h = $(this).outerHeight() || 0; + if (h > fixedHeaderHeight) { + fixedHeaderHeight = h; + } + }); + + var topReserve = Math.max(sideSpacing, 14) + Math.max(fixedHeaderHeight, 64) + 28; + + var top = Math.max(0, $editor.offset().top - topReserve); + $('html, body').stop(true).animate({scrollTop: top}, 220); + } + + function refreshPanels(selectors) { + selectors = selectors || []; + if (!selectors.length) { + return $.Deferred().resolve().promise(); + } + + return $.get(window.location.href).done(function(html) { + var $doc = $('
').append($.parseHTML(html, document, true)); + selectors.forEach(function(selector) { + var $next = $doc.find(selector).first(); + if ($next.length) { + $(selector).replaceWith($next); + } + }); + }); + } + + window.addEventListener('message', function(event) { + var data = event.data || {}; + if (!data || typeof data !== 'object' || data.source !== 'animal-inline-editor') { + return; + } + + if (data.type === 'cancel') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + return; + } + + if (data.type === 'saved') { + if (data.collapseId) { + $('#' + data.collapseId).collapse('hide'); + } + + var selectors = $.isArray(data.refreshSelectors) && data.refreshSelectors.length + ? data.refreshSelectors + : ['#animal-medical-panel', '#animal-progress-panel', '#animal-gallery-panel']; + + refreshPanels(selectors); + } + }); + + $(document) + .off('shown.bs.collapse.animalInlineScroll', '.animal-inline-editor') + .on('shown.bs.collapse.animalInlineScroll', '.animal-inline-editor', function() { + scrollInlineEditorIntoView(this); + }); + + $(document).on('submit', '#animal-gallery-upload', function(event) { + event.preventDefault(); + var form = this; + var filesInput = form.querySelector('#galleryImages'); + var selectedCount = filesInput && filesInput.files ? filesInput.files.length : 0; + if (selectedCount > 10) { + window.alert('You can upload up to 10 images at a time.'); + return; + } + + var formData = new FormData(form); + + $.ajax({ + url: form.action, + type: 'POST', + data: formData, + processData: false, + contentType: false + }).done(function() { + var shouldReopenModal = selectedCount > 0; + if (form && typeof form.reset === 'function') { + form.reset(); + } + $('#animal-gallery-manage-modal').modal('hide'); + refreshPanels(['#animal-gallery-panel']).done(function() { + if (shouldReopenModal) { + $('#animal-gallery-manage-modal').modal('show'); + } + }); + }); + }); + + $(document).on('click', '.js-ajax-gallery-remove', function(event) { + event.preventDefault(); + + var $link = $(this); + var confirmText = $link.data('confirmMessage') || $link.data('confirm'); + if (confirmText && !window.confirm(confirmText)) { + return; + } + + $.post($link.attr('href'), getCsrfPayload()).done(function() { + refreshPanels(['#animal-gallery-panel']); + }); + }); +})(); +JS +, \yii\web\View::POS_END); +?> + + + + + + + + + +
+
+
+
+ + + + + ' . Yii::t('AnimalManagementModule.base', 'Add'), $space->createUrl('/animal_management/animals/medical-visits', ['id' => $animal->id, 'inlineMedicalAdd' => 1]) . '#medical-add-inline', [ + 'class' => 'btn btn-xs btn-success', + 'title' => Yii::t('AnimalManagementModule.base', 'Add Medical Visit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Add Medical Visit'), + ]) ?> + +
+
+ +
+ + 'BP', + 'oxygen' => 'O₂', + ]; + ?> + + getCustomFieldDisplayValues($canManage); + $visitFieldsByKey = []; + $additionalVisitFields = []; + $medicalMedia = ''; + foreach ($visitCustomValues as $customField) { + $fieldKey = (string)($customField['field_key'] ?? ''); + if (in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + + $fieldValue = trim((string)($customField['value'] ?? '')); + if ($fieldValue === '') { + continue; + } + + if ($fieldKey === 'medical_media_reference' || $fieldKey === 'media_reference') { + $medicalMedia = $fieldValue; + continue; + } + + if (in_array($fieldKey, $knownMedicalKeys, true)) { + $visitFieldsByKey[$fieldKey] = [ + 'label' => (string)($medicalVitalLabelOverrides[$fieldKey] ?? ($customField['label'] ?? $fieldKey)), + 'value' => $fieldValue, + ]; + continue; + } + + $additionalVisitFields[] = [ + 'label' => (string)($customField['label'] ?? $fieldKey), + 'value' => $fieldValue, + ]; + } + + $hasMedicalMedia = $medicalMedia !== '' && (preg_match('/^https?:\/\//i', $medicalMedia) || substr($medicalMedia, 0, 1) === '/'); + $visitDateDisplay = DateDisplayHelper::format((string)$visit->visit_at); + ?> +
+ + <?= Yii::t('AnimalManagementModule.base', 'Medical media') ?> + +
+
+ +
+ visit_at) ?> + +
+ + + : + +
+ +
+ +
+
+ + ', + '#medical-edit-inline-' . (int)$visit->id, + [ + 'class' => 'btn btn-xs btn-default', + 'data-toggle' => 'collapse', + 'title' => Yii::t('AnimalManagementModule.base', 'Edit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Edit'), + ] + ) ?> + +
+ + provider_name)): ?> +
: provider_name) ?>
+ + + notes)): ?> +
+
notes)) ?>
+ + recommendations)): ?> +
+
recommendations)) ?>
+ + + +
+
+ + + +
+
+ + + + + + +
+
+ +
+ +
+ + + +
+
+ +
:
+ +
+ +
+ + +
+ +
+ +
+
+ + + +
+
+
+ +
+
+
+ + + + + ' . Yii::t('AnimalManagementModule.base', 'Add'), $space->createUrl('/animal_management/animals/progress-updates', ['id' => $animal->id, 'inlineProgressAdd' => 1]) . '#progress-add-inline', [ + 'class' => 'btn btn-xs btn-success', + 'title' => Yii::t('AnimalManagementModule.base', 'Add Progress Update'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Add Progress Update'), + ]) ?> + +
+
+ +
+ + + getCustomFieldDisplayValues($canManage); + $mediaReference = ''; + $progressCustomDisplayValues = []; + foreach ($progressCustomValues as $customField) { + if ((string)($customField['field_key'] ?? '') === 'media_reference') { + $mediaReference = trim((string)$customField['value']); + continue; + } + + $progressCustomDisplayValues[] = $customField; + } + + $hasMediaImage = $mediaReference !== '' && (preg_match('/^https?:\/\//i', $mediaReference) || substr($mediaReference, 0, 1) === '/'); + ?> +
+ + <?= Yii::t('AnimalManagementModule.base', 'Progress media') ?> + +
+
+
+ update_at)) ?> + weight) || !empty($update->vitals)): ?> +
+ weight)): ?>: weight) ?> + vitals)): ?> +
+ +
+ +
+
+ + ', + '#progress-edit-inline-' . (int)$update->id, + [ + 'class' => 'btn btn-xs btn-default', + 'data-toggle' => 'collapse', + 'title' => Yii::t('AnimalManagementModule.base', 'Edit'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Edit'), + ] + ) ?> + +
+ + vitals)): ?>
vitals)) ?>
+ behavior_notes)): ?>
behavior_notes)) ?>
+ meal_plan_changes)): ?>
meal_plan_changes)) ?>
+ housing_changes)): ?>
housing_changes)) ?>
+ medical_concerns)): ?>
medical_concerns)) ?>
+ + +
+
+ +
:
+ +
+ +
+ + +
+ +
+ +
+
+ + + +
+
+
+
+ +
+
+ Transfer Timeline') ?> + + ' . Yii::t('AnimalManagementModule.base', 'Add'), $space->createUrl('/animal_management/animals/transfer', ['id' => $animal->id]), [ + 'class' => 'btn btn-xs btn-success', + 'title' => Yii::t('AnimalManagementModule.base', 'Request Transfer'), + 'aria-label' => Yii::t('AnimalManagementModule.base', 'Request Transfer'), + ]) ?> + +
+
+ +
+ + + + + + + + + + + + + getFromSpace(); $toSpace = $transfer->getToSpace(); ?> + + + + + + + + +
name : Yii::t('AnimalManagementModule.base', 'Unknown')) ?>name : Yii::t('AnimalManagementModule.base', 'Unknown')) ?>status] ?? $transfer->status) ?>updated_at)) ?>
+ +
+
+ +
+
Transfer Audit Log') ?>
+
+ +
+ + + + + + + + + + + + + + createdByUser; ?> + + + + + + + + + +
created_at)) ?>displayName : Yii::t('AnimalManagementModule.base', 'System')) ?>event_type) ?>from_status ?: '-') . ' -> ' . (string)($event->to_status ?: '-')) ?>message) ?>
+ +
+
diff --git a/views/gallery/animal_gallery_view.php b/views/gallery/animal_gallery_view.php new file mode 100644 index 0000000..e1f153b --- /dev/null +++ b/views/gallery/animal_gallery_view.php @@ -0,0 +1,104 @@ +getDisplayName() : Yii::t('AnimalManagementModule.base', 'Animal'); +$backUrl = Url::toGalleryOverview($container); +$animalUrl = $animal instanceof Animal + ? $container->createUrl('/animal_management/animals/view', ['id' => (int)$animal->id]) + : null; +$uiGalleryId = 'animal-gallery-native-' . (int)$gallery->id; +$descriptionText = preg_replace('/\s*\[animal-gallery:\d+\]\s*/', ' ', (string)$gallery->description); +$descriptionText = trim((string)$descriptionText); +$headerTitle = trim((string)$gallery->title); +$headerTitle = preg_replace('/\s+Gallery\s*$/i', '', $headerTitle); +if ($headerTitle === '') { + $headerTitle = $animalName; +} +?> + + diff --git a/views/settings/index.php b/views/settings/index.php new file mode 100644 index 0000000..95ca2c3 --- /dev/null +++ b/views/settings/index.php @@ -0,0 +1,168 @@ + + +
+
Animal Management Settings') ?>
+ + + + + +
+

+ +
+ : + +
+ +

+ + errorSummary($displaySettingsForm, ['showAllErrors' => true]) ?> +
+
+ field($displaySettingsForm, 'search_block_heading')->textInput(['maxlength' => 190]) ?> +
+
+
+
+ + tile_fields, + DisplaySettingsForm::fieldOptions(), + ['separator' => '
'] + ) ?> +

+
+
+ + detail_fields, + DisplaySettingsForm::fieldOptions(), + ['separator' => '
'] + ) ?> +
+
+
+ submit() ?> +
+ + + canUseFieldDefinition()): ?> +
+ +
+ + + + hasErrors('rows')): ?> +
+ '', 'footer' => '', 'showAllErrors' => true]) ?> +
+ + +

+ + rows)): ?> +
+ +
+ + + + + + + + + + + + + + + + rows as $index => $row): ?> + + + + + + + + + + + + + + +
+ + + + + + + 'form-control input-sm']) ?> 'form-control input-sm']) ?> + + + + $isCore]) ?> + + + + + $isCore]) ?> + + + + + 'form-control input-sm'] + ) ?> + 'form-control input-sm', 'style' => 'max-width:90px;']) ?> + + + + - + +
+
+ + +

+
+
field($fieldSettingsForm, 'new_field_key')->textInput(['placeholder' => 'e.g. coat_color']) ?>
+
field($fieldSettingsForm, 'new_label') ?>
+
field($fieldSettingsForm, 'new_input_type')->dropDownList(FieldDefinitionSettingsForm::inputTypeOptions()) ?>
+
field($fieldSettingsForm, 'new_group_key') ?>
+
field($fieldSettingsForm, 'new_sort_order')->input('number') ?>
+
+
+
field($fieldSettingsForm, 'new_visibility')->dropDownList(FieldDefinitionSettingsForm::visibilityOptions()) ?>
+
field($fieldSettingsForm, 'new_required')->checkbox() ?>
+
field($fieldSettingsForm, 'new_options')->textInput(['placeholder' => 'Optional JSON options']) ?>
+
+ + submit() ?> + + +
+
diff --git a/views/widgets/searchAnimalProfilesBlock.php b/views/widgets/searchAnimalProfilesBlock.php new file mode 100644 index 0000000..490c607 --- /dev/null +++ b/views/widgets/searchAnimalProfilesBlock.php @@ -0,0 +1,42 @@ + + +
+
+ + + + +
+
+ + +
+ +
+ +
    + +
  • + getDisplayName()) ?> +
    + animal_uid) ?> + species)): ?> + · species) ?> + +
    +
  • + +
+ + + + + diff --git a/widgets/SearchAnimalProfilesBlock.php b/widgets/SearchAnimalProfilesBlock.php new file mode 100644 index 0000000..c3a6650 --- /dev/null +++ b/widgets/SearchAnimalProfilesBlock.php @@ -0,0 +1,306 @@ +contentContainer->moduleManager->isEnabled('animal_management')) { + return '
' + . Yii::t('AnimalManagementModule.base', 'Animal profiles are not enabled for this rescue.') + . '
'; + } + + $queryValue = trim((string)Yii::$app->request->get('q', '')); + $showAll = ((int)Yii::$app->request->get('animalFeedAll', 0)) === 1; + $countParam = (int)Yii::$app->request->get('animalFeedCount', $this->limit); + if ($countParam < $this->limit) { + $countParam = $this->limit; + } + if ($countParam > 200) { + $countParam = 200; + } + + $query = Animal::find()->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + + if ($queryValue !== '') { + $query->andWhere([ + 'or', + ['like', 'animal_uid', $queryValue], + ['like', 'name', $queryValue], + ['like', 'species', $queryValue], + ]); + } + + $totalCount = (int)$query->count(); + $displayCount = $showAll ? $totalCount : min($countParam, $totalCount); + + $settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer); + $heading = trim((string)$settings->get('searchBlockHeading', DisplaySettingsForm::DEFAULT_SEARCH_BLOCK_HEADING)); + if ($heading === '') { + $heading = DisplaySettingsForm::DEFAULT_SEARCH_BLOCK_HEADING; + } + + $tileFieldsRaw = $settings->get('tileFields', json_encode(DisplaySettingsForm::DEFAULT_TILE_FIELDS)); + $tileFields = $this->normalizeDisplayFields($tileFieldsRaw, DisplaySettingsForm::DEFAULT_TILE_FIELDS); + + $animals = $query + ->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC]) + ->limit($displayCount) + ->all(); + + $animalIds = array_map(static function (Animal $animal): int { + return (int)$animal->id; + }, $animals); + + $latestMedicalVisitByAnimal = $this->resolveLatestMedicalVisits($animalIds); + $animalImageUrls = $this->resolveAnimalImageUrls($animalIds, ['profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'], false); + $tileFieldOverrides = $this->resolveDisplayFieldOverrides($animalIds, 'tile_display_fields'); + + $hasMore = !$showAll && $displayCount < $totalCount; + $nextCount = min($displayCount + $this->limit, $totalCount); + + $viewFile = $this->getViewPath() . '/searchAnimalProfilesBlock.php'; + if (!is_file($viewFile)) { + return '
' + . Yii::t('AnimalManagementModule.base', 'Animal search is temporarily unavailable.') + . '
'; + } + + return $this->render('searchAnimalProfilesBlock', [ + 'animals' => $animals, + 'contentContainer' => $this->contentContainer, + 'queryValue' => $queryValue, + 'heading' => $heading, + 'tileFields' => $tileFields, + 'tileFieldOverrides' => $tileFieldOverrides, + 'latestMedicalVisitByAnimal' => $latestMedicalVisitByAnimal, + 'animalImageUrls' => $animalImageUrls, + 'totalCount' => $totalCount, + 'displayCount' => $displayCount, + 'hasMore' => $hasMore, + 'nextCount' => $nextCount, + 'showAll' => $showAll, + ]); + } + + private function normalizeDisplayFields($raw, array $default): array + { + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $default; + } + $raw = $decoded; + } + + if (!is_array($raw)) { + return $default; + } + + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $normalized = []; + foreach ($raw as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + return !empty($normalized) ? $normalized : $default; + } + + private function resolveLatestMedicalVisits(array $animalIds): array + { + $animalIds = array_values(array_unique(array_map('intval', $animalIds))); + if (empty($animalIds)) { + return []; + } + + $visits = AnimalMedicalVisit::find() + ->where(['animal_id' => $animalIds]) + ->orderBy(['animal_id' => SORT_ASC, 'visit_at' => SORT_DESC, 'id' => SORT_DESC]) + ->all(); + + $result = []; + foreach ($visits as $visit) { + $animalId = (int)$visit->animal_id; + if (!isset($result[$animalId])) { + $result[$animalId] = $visit; + } + } + + return $result; + } + + private function resolveAnimalImageUrls(array $animalIds, array $imageFieldOrder = [], bool $allowGalleryFallback = false): array + { + $animalIds = array_values(array_unique(array_map('intval', $animalIds))); + if (empty($animalIds)) { + return []; + } + + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return []; + } + + if (empty($imageFieldOrder)) { + $imageFieldOrder = ['cover_image_url', 'profile_image_url', 'photo_url', 'image_url', 'profile_image', 'photo']; + } + + $definitions = RescueFieldDefinition::find() + ->select(['id', 'field_key']) + ->where([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $imageFieldOrder, + 'is_active' => 1, + ]) + ->all(); + + if (empty($definitions)) { + return []; + } + + $definitionPriority = []; + foreach ($definitions as $definition) { + $priority = array_search((string)$definition->field_key, $imageFieldOrder, true); + $definitionPriority[(int)$definition->id] = $priority === false ? 999 : (int)$priority; + } + + if (empty($definitionPriority)) { + return []; + } + + $valueRows = AnimalFieldValue::find() + ->where(['animal_id' => $animalIds, 'field_definition_id' => array_keys($definitionPriority)]) + ->all(); + + $imageUrls = []; + $chosenPriorityByAnimal = []; + foreach ($valueRows as $valueRow) { + $animalId = (int)$valueRow->animal_id; + $valueText = trim((string)$valueRow->value_text); + if ($valueText === '') { + continue; + } + + $priority = $definitionPriority[(int)$valueRow->field_definition_id] ?? 999; + if (!isset($chosenPriorityByAnimal[$animalId]) || $priority < $chosenPriorityByAnimal[$animalId]) { + $chosenPriorityByAnimal[$animalId] = $priority; + $imageUrls[$animalId] = $valueText; + } + } + + $missingAnimalIds = []; + foreach ($animalIds as $animalId) { + if (!isset($imageUrls[$animalId])) { + $missingAnimalIds[] = (int)$animalId; + } + } + + if ($allowGalleryFallback && !empty($missingAnimalIds) && Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) !== null) { + $galleryItems = AnimalGalleryItem::find() + ->where(['animal_id' => $missingAnimalIds]) + ->orderBy(['animal_id' => SORT_ASC, 'id' => SORT_DESC]) + ->all(); + + foreach ($galleryItems as $galleryItem) { + $animalId = (int)$galleryItem->animal_id; + if (isset($imageUrls[$animalId])) { + continue; + } + + $url = trim((string)$galleryItem->getImageUrl()); + if ($url === '') { + continue; + } + + $imageUrls[$animalId] = $url; + } + } + + return $imageUrls; + } + + private function resolveDisplayFieldOverrides(array $animalIds, string $fieldKey): array + { + $animalIds = array_values(array_unique(array_map('intval', $animalIds))); + if (empty($animalIds)) { + return []; + } + + if (!class_exists(RescueFieldDefinition::class) + || Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null + || Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null + ) { + return []; + } + + $definition = RescueFieldDefinition::findOne([ + 'module_id' => 'animal_management', + 'group_key' => 'animal_profile', + 'field_key' => $fieldKey, + ]); + + if (!$definition instanceof RescueFieldDefinition) { + return []; + } + + $rows = AnimalFieldValue::find() + ->where(['animal_id' => $animalIds, 'field_definition_id' => (int)$definition->id]) + ->all(); + + $allowed = array_keys(DisplaySettingsForm::fieldOptions()); + $result = []; + foreach ($rows as $row) { + $raw = trim((string)$row->value_text); + if ($raw === '') { + continue; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + $decoded = array_map('trim', explode(',', $raw)); + } + + $normalized = []; + foreach ($decoded as $field) { + $field = trim((string)$field); + if ($field === '' || !in_array($field, $allowed, true)) { + continue; + } + + if (!in_array($field, $normalized, true)) { + $normalized[] = $field; + } + } + + if (!empty($normalized)) { + $result[(int)$row->animal_id] = $normalized; + } + } + + return $result; + } +} diff --git a/widgets/stream/AnimalStreamEntryWallEntry.php b/widgets/stream/AnimalStreamEntryWallEntry.php new file mode 100644 index 0000000..d3b732d --- /dev/null +++ b/widgets/stream/AnimalStreamEntryWallEntry.php @@ -0,0 +1,26 @@ +render('wall-entry', [ + 'entry' => $this->model, + ]); + } + + protected function getTitle() + { + return $this->model->getContentDescription(); + } +} diff --git a/widgets/stream/views/wall-entry.php b/widgets/stream/views/wall-entry.php new file mode 100644 index 0000000..51f7570 --- /dev/null +++ b/widgets/stream/views/wall-entry.php @@ -0,0 +1,228 @@ +animal; +if (!$animal instanceof Animal) { + return; +} + +$medicalVisit = $entry->medicalVisit; +$progressUpdate = $entry->progressUpdate; +$isMedical = (string)$entry->entry_type === AnimalStreamEntry::TYPE_MEDICAL && $medicalVisit instanceof AnimalMedicalVisit; +$isProgress = (string)$entry->entry_type === AnimalStreamEntry::TYPE_PROGRESS && $progressUpdate instanceof AnimalProgressUpdate; + +if (!$isMedical && !$isProgress) { + return; +} + +$animalName = $animal->getDisplayName(); + +$mediaReference = ''; +$chipRows = []; +$dateText = ''; +$detailRows = []; + +if ($isProgress) { + $customValues = $progressUpdate->getCustomFieldDisplayValues(true); + $additionalFields = []; + foreach ($customValues as $customField) { + if ((string)($customField['field_key'] ?? '') === 'media_reference') { + $mediaReference = trim((string)$customField['value']); + continue; + } + + $additionalFields[] = [ + 'label' => (string)$customField['label'], + 'value' => (string)$customField['value'], + ]; + } + + $dateText = DateDisplayHelper::format((string)$progressUpdate->update_at); + if (trim((string)$progressUpdate->weight) !== '') { + $chipRows[] = Yii::t('AnimalManagementModule.base', 'Weight') . ': ' . trim((string)$progressUpdate->weight); + } + if (trim((string)$progressUpdate->vitals) !== '') { + $chipRows[] = Yii::t('AnimalManagementModule.base', 'Vitals'); + } + + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Animal'), 'value' => $animalName]; + if (!empty($progressUpdate->vitals)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Vitals'), 'value' => (string)$progressUpdate->vitals]; + } + if (!empty($progressUpdate->behavior_notes)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Behavior'), 'value' => (string)$progressUpdate->behavior_notes]; + } + if (!empty($progressUpdate->meal_plan_changes)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Meal Plan'), 'value' => (string)$progressUpdate->meal_plan_changes]; + } + if (!empty($progressUpdate->housing_changes)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Housing'), 'value' => (string)$progressUpdate->housing_changes]; + } + if (!empty($progressUpdate->medical_concerns)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Medical Concerns'), 'value' => (string)$progressUpdate->medical_concerns]; + } + if (!empty($additionalFields)) { + $text = ''; + foreach ($additionalFields as $field) { + $text .= $field['label'] . ': ' . $field['value'] . "\n"; + } + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Additional Fields'), 'value' => trim($text)]; + } +} elseif ($isMedical) { + $hiddenMedicalKeys = [ + 'second_physician_name', + 'second_physician_business_name', + 'second_physician_street_address', + 'second_physician_city', + 'second_physician_state', + 'second_physician_zip', + 'second_physician_cell_phone', + 'second_physician_business_phone', + 'second_physician_license_number', + 'previous_physicians', + ]; + + $knownMedicalKeys = [ + 'weight', + 'pulse', + 'blood_pressure', + 'oxygen', + 'chronic_conditions', + 'acute_conditions', + 'special_needs', + 'date_of_most_recent_medical_visit', + 'physician_name', + 'physician_business_name', + 'physician_street_address', + 'physician_city', + 'physician_state', + 'physician_zip', + 'physician_cell_phone', + 'physician_business_phone', + 'physician_license_number', + 'medical_media_reference', + 'media_reference', + ]; + + $vitalLabelOverrides = [ + 'blood_pressure' => 'BP', + 'oxygen' => 'O₂', + ]; + + $fieldsByKey = []; + $additionalFields = []; + foreach ($medicalVisit->getCustomFieldDisplayValues(true) as $customField) { + $fieldKey = (string)($customField['field_key'] ?? ''); + if (in_array($fieldKey, $hiddenMedicalKeys, true)) { + continue; + } + + $fieldValue = trim((string)($customField['value'] ?? '')); + if ($fieldValue === '') { + continue; + } + + if ($fieldKey === 'medical_media_reference' || $fieldKey === 'media_reference') { + $mediaReference = $fieldValue; + continue; + } + + if (in_array($fieldKey, $knownMedicalKeys, true)) { + $fieldsByKey[$fieldKey] = [ + 'label' => (string)($vitalLabelOverrides[$fieldKey] ?? ($customField['label'] ?? $fieldKey)), + 'value' => $fieldValue, + ]; + continue; + } + + $additionalFields[] = [ + 'label' => (string)($customField['label'] ?? $fieldKey), + 'value' => $fieldValue, + ]; + } + + $dateText = DateDisplayHelper::format((string)$medicalVisit->visit_at); + foreach (['weight', 'pulse', 'blood_pressure', 'oxygen'] as $vitalKey) { + if (!empty($fieldsByKey[$vitalKey]['value'])) { + $chipRows[] = $fieldsByKey[$vitalKey]['label'] . ': ' . $fieldsByKey[$vitalKey]['value']; + } + } + + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Animal'), 'value' => $animalName]; + if (!empty($medicalVisit->provider_name)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Provider'), 'value' => (string)$medicalVisit->provider_name]; + } + if (!empty($medicalVisit->notes)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Clinical Notes'), 'value' => (string)$medicalVisit->notes]; + } + if (!empty($medicalVisit->recommendations)) { + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Recommendations'), 'value' => (string)$medicalVisit->recommendations]; + } + + foreach (['chronic_conditions', 'acute_conditions', 'special_needs'] as $conditionKey) { + if (empty($fieldsByKey[$conditionKey]['value'])) { + continue; + } + $detailRows[] = [ + 'label' => (string)$fieldsByKey[$conditionKey]['label'], + 'value' => (string)$fieldsByKey[$conditionKey]['value'], + ]; + } + + if (!empty($fieldsByKey['date_of_most_recent_medical_visit']['value'])) { + $detailRows[] = [ + 'label' => (string)$fieldsByKey['date_of_most_recent_medical_visit']['label'], + 'value' => DateDisplayHelper::format((string)$fieldsByKey['date_of_most_recent_medical_visit']['value']), + ]; + } + + if (!empty($additionalFields)) { + $text = ''; + foreach ($additionalFields as $field) { + $text .= $field['label'] . ': ' . $field['value'] . "\n"; + } + $detailRows[] = ['label' => Yii::t('AnimalManagementModule.base', 'Additional Fields'), 'value' => trim($text)]; + } +} + +$hasMediaImage = $mediaReference !== '' && (preg_match('/^https?:\/\//i', $mediaReference) || substr($mediaReference, 0, 1) === '/'); +?> + +
+ + <?= Yii::t('AnimalManagementModule.base', 'Animal stream media') ?> + +
+
+
+ + +
+ + + +
+ +
+ +
+
+ +
+ +
+
+ +
+
+
diff --git a/widgets/views/searchAnimalProfilesBlock.php b/widgets/views/searchAnimalProfilesBlock.php new file mode 100644 index 0000000..12700bf --- /dev/null +++ b/widgets/views/searchAnimalProfilesBlock.php @@ -0,0 +1,95 @@ + $tileFieldOverrides */ +/* @var array $latestMedicalVisitByAnimal */ +/* @var array $animalImageUrls */ +/* @var int $totalCount */ +/* @var int $displayCount */ +/* @var bool $hasMore */ +/* @var int $nextCount */ +/* @var bool $showAll */ + +$moduleEnabled = $contentContainer->moduleManager->isEnabled('animal_management'); +$currentParams = Yii::$app->request->getQueryParams(); +$buildProfileUrl = static function (array $overrides) use ($contentContainer, $currentParams): string { + $params = array_merge($currentParams, $overrides); + return $contentContainer->createUrl('/space_profiles/profile/view', $params); +}; +?> + +

+ + +
+
+ + + + + + +
+
+ +
+ +
+ + + +
+ +
+ +
+ + id; ?> +
+ renderFile(Yii::getAlias('@app/modules/animal_management/views/animals/_tile.php'), [ + 'animal' => $animal, + 'contentContainer' => $contentContainer, + 'lastMedical' => $latestMedicalVisitByAnimal[$animalId] ?? null, + 'imageUrl' => $animalImageUrls[$animalId] ?? '', + 'tileFields' => $tileFieldOverrides[$animalId] ?? $tileFields, + 'showMedicalIcon' => true, + ]) ?> +
+ +
+ + + +
+ + + + + + 0): ?> + + + + + 0): ?> + + $displayCount, 'total' => $totalCount]) ?> + + +
+