Compare commits
319 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c11147e95 | ||
|
|
31a2a66127 | ||
|
|
e44657bf7e | ||
|
|
112cc56922 | ||
|
|
7885bf9d11 | ||
|
|
345bfde306 | ||
|
|
3d79fe1abc | ||
|
|
23d1476870 | ||
|
|
2af31d0e5f | ||
|
|
42ce99eeba | ||
|
|
9174254165 | ||
|
|
1d2cca5837 | ||
|
|
93b04a0c2a | ||
|
|
7743c2e126 | ||
|
|
e6eeacb6d4 | ||
|
|
84725b2dfa | ||
|
|
b2b7a07e0c | ||
|
|
61e1efd81b | ||
|
|
1fb5886d06 | ||
|
|
bd756f2365 | ||
|
|
999ff3e977 | ||
|
|
cb37de6da4 | ||
|
|
119a1712b0 | ||
|
|
f5e876122d | ||
|
|
66f7dce701 | ||
|
|
6fdf10b2b1 | ||
|
|
71388ec6a5 | ||
|
|
064bdf5395 | ||
|
|
f83ec6e78f | ||
|
|
9345d3e525 | ||
|
|
fac9ad8cef | ||
|
|
04952d91ed | ||
|
|
27c60bb6f5 | ||
|
|
53932e9e16 | ||
|
|
5ab9c5f736 | ||
|
|
78ed332d06 | ||
|
|
c5c9677ffc | ||
|
|
ba5c6c39ba | ||
|
|
346e5df7ee | ||
|
|
9c3013bb24 | ||
|
|
5efd1054f4 | ||
|
|
20085cab5d | ||
|
|
ea1fc9b799 | ||
|
|
6dd9ed1232 | ||
|
|
67268ac2b7 | ||
|
|
39c9179888 | ||
|
|
631d838834 | ||
|
|
9638e02eb8 | ||
|
|
c46118be3b | ||
|
|
0093bf4d53 | ||
|
|
25b61b73a0 | ||
|
|
8508e7e1b8 | ||
|
|
9ca998e64a | ||
|
|
c328d1cd10 | ||
|
|
5c0e761dfa | ||
|
|
6106ac8208 | ||
|
|
45f347ebef | ||
|
|
b07a252523 | ||
|
|
b0b7d40c73 | ||
|
|
e64529468c | ||
|
|
2bdfb6a8b6 | ||
|
|
74b4b2df4e | ||
|
|
cab785172e | ||
|
|
97f88eaf2c | ||
|
|
af92a361a5 | ||
|
|
a46cca5cd8 | ||
|
|
812956abcc | ||
|
|
d91f6ff2e3 | ||
|
|
774b2614f0 | ||
|
|
e43e515614 | ||
|
|
2b996d30bf | ||
|
|
991b716b33 | ||
|
|
53a0e1491d | ||
|
|
827c5c8938 | ||
|
|
9c8e4ae558 | ||
|
|
7e15eb7c7a | ||
|
|
a10d4d2de9 | ||
|
|
249b2759b6 | ||
|
|
d7f88d3103 | ||
|
|
960e6d3517 | ||
|
|
7a1e2cd2c3 | ||
|
|
d36fbbbf27 | ||
|
|
2a3774f437 | ||
|
|
dabdd2b417 | ||
|
|
350068706c | ||
|
|
88adfe0e50 | ||
|
|
7fbe3d85a3 | ||
|
|
eb0a588164 | ||
|
|
29b3abd317 | ||
|
|
c85051161b | ||
|
|
c3a59a46db | ||
|
|
fae59221d3 | ||
|
|
afbc6c1db3 | ||
|
|
c49c130e9e | ||
|
|
51f0e6285b | ||
|
|
03afb98452 | ||
|
|
acce67f514 | ||
|
|
8e966965f5 | ||
|
|
e0e302e1cf | ||
|
|
8d3029c877 | ||
|
|
c1994887ef | ||
|
|
6343b4a17f | ||
|
|
8a6c085288 | ||
|
|
92a1baced4 | ||
|
|
4318c59976 | ||
|
|
c3ad718799 | ||
|
|
dee17a1da8 | ||
|
|
00d5abae5f | ||
|
|
5912995164 | ||
|
|
6f095210d6 | ||
|
|
b9e34ff25c | ||
|
|
8e7c48f532 | ||
|
|
20ace694fe | ||
|
|
3f5accc0f0 | ||
|
|
7bb6d9bcc3 | ||
|
|
142899cfd7 | ||
|
|
d505677a74 | ||
|
|
e9ee74cd01 | ||
|
|
0fa1aac748 | ||
|
|
c794f92932 | ||
|
|
c90dd1f2ac | ||
|
|
85b62dfdf8 | ||
|
|
8f29e8f5de | ||
|
|
ece6ce737b | ||
|
|
00599b6943 | ||
|
|
4f47db9258 | ||
|
|
64c481e963 | ||
|
|
0aaf0419c3 | ||
|
|
c0454613a8 | ||
|
|
92872e7e1c | ||
|
|
2191417151 | ||
|
|
5f389ea3b0 | ||
|
|
f76f351244 | ||
|
|
735eef6e9b | ||
|
|
4bfec37a53 | ||
|
|
1ca915a0a3 | ||
|
|
6b5e4878fb | ||
|
|
bbf46577dc | ||
|
|
de06c335ac | ||
|
|
62f31960ab | ||
|
|
129028518d | ||
|
|
e0b8b86e06 | ||
|
|
e208e3ba56 | ||
|
|
0ce88ab817 | ||
|
|
23ff69026e | ||
|
|
7d9fdb79cc | ||
|
|
3d871604ae | ||
|
|
1adebe85b9 | ||
|
|
defbfc6406 | ||
|
|
55416bc633 | ||
|
|
c469f64c79 | ||
|
|
9432da467e | ||
|
|
25986b7bd5 | ||
|
|
751bd4a1c8 | ||
|
|
0e313b80ca | ||
|
|
5014a75fbc | ||
|
|
9e5b9fdaad | ||
|
|
08dfe62312 | ||
|
|
f623cd0179 | ||
|
|
414531524c | ||
|
|
d75a957183 | ||
|
|
63d3a7fc8e | ||
|
|
0be747a483 | ||
|
|
3ceb08bc31 | ||
|
|
ad5a7e51b9 | ||
|
|
691178857f | ||
|
|
a38f09cf7b | ||
|
|
375cfc6b18 | ||
|
|
aa88dc340d | ||
|
|
7004bf375e | ||
|
|
80889293bf | ||
|
|
d754c4d29e | ||
|
|
8de32e27d3 | ||
|
|
3bae2c282c | ||
|
|
14b5aaa0c9 | ||
|
|
aafcf147cf | ||
|
|
7e8f9e65fb | ||
|
|
67739750ca | ||
|
|
5f6a7b92bf | ||
|
|
ff3cab570a | ||
|
|
11024ddc38 | ||
|
|
71303a1050 | ||
|
|
3af0e83d26 | ||
|
|
a866a26e9f | ||
|
|
154a4dc076 | ||
|
|
7900e020f5 | ||
|
|
974efb36da | ||
|
|
45d84b8b3c | ||
|
|
fdfb46ed79 | ||
|
|
ddcef0fb2c | ||
|
|
0254b16556 | ||
|
|
a9a52ca85f | ||
|
|
708d880b52 | ||
|
|
57406989d4 | ||
|
|
5d7e46b026 | ||
|
|
8e034e59f8 | ||
|
|
43951d4542 | ||
|
|
3cc0f41d8c | ||
|
|
5f34ae558c | ||
|
|
70d51d580d | ||
|
|
b7929672fb | ||
|
|
bd28849a9f | ||
|
|
4d21aadcd4 | ||
|
|
373a824fdb | ||
|
|
a5d9a60d5c | ||
|
|
2537b53506 | ||
|
|
bbc2d2088a | ||
|
|
5734a06ab3 | ||
|
|
75a1038245 | ||
|
|
017691b7b1 | ||
|
|
d4f8a40982 | ||
|
|
ebdee1e3f0 | ||
|
|
ce97a474f9 | ||
|
|
4ee3d0d129 | ||
|
|
bd5abdec0e | ||
|
|
995533974e | ||
|
|
156e94f931 | ||
|
|
e38c4963ef | ||
|
|
d7efbb5c47 | ||
|
|
3cd219e7b3 | ||
|
|
3d65a09038 | ||
|
|
f52c376a49 | ||
|
|
5a7c1363ee | ||
|
|
2c40c2ef06 | ||
|
|
b5ff16c318 | ||
|
|
4d7b818d23 | ||
|
|
b314ebc07f | ||
|
|
254ed5ade0 | ||
|
|
40e4ea931e | ||
|
|
030c13b67a | ||
|
|
28fedead6c | ||
|
|
14ccce1934 | ||
|
|
2d808f17bb | ||
|
|
6d227fa368 | ||
|
|
07757e747e | ||
|
|
d4e19646ee | ||
|
|
c901114fc0 | ||
|
|
186ab35fd4 | ||
|
|
3c31baf20d | ||
|
|
c7485930a8 | ||
|
|
c7d06671d6 | ||
|
|
b35bd0880a | ||
|
|
c70e2abf8a | ||
|
|
fc05cf61fa | ||
|
|
48abd9020d | ||
|
|
9afd4011a6 | ||
|
|
01528d9ebe | ||
|
|
a386fd19fc | ||
|
|
d20f1aa105 | ||
|
|
e5f6984078 | ||
|
|
65d1fdd3f0 | ||
|
|
796f6f364a | ||
|
|
61b345d473 | ||
|
|
a30b2e751b | ||
|
|
af205acb75 | ||
|
|
24546aea3c | ||
|
|
d2953e9409 | ||
|
|
935fe06f9e | ||
|
|
18d63a3375 | ||
|
|
3da86965e1 | ||
|
|
5aa54dbd86 | ||
|
|
58b262fe32 | ||
|
|
f8c4e63b3f | ||
|
|
78dfdbe162 | ||
|
|
770da45dbf | ||
|
|
3e96297699 | ||
|
|
f3e49d3044 | ||
|
|
05ab78942c | ||
|
|
827436af15 | ||
|
|
233d3cb989 | ||
|
|
8842d6a626 | ||
|
|
f1635bcef8 | ||
|
|
3d327a1735 | ||
|
|
a6ce294497 | ||
|
|
1998d15b25 | ||
|
|
f47b6d51f2 | ||
|
|
23f6b1877f | ||
|
|
3ed91545d1 | ||
|
|
c035c834d4 | ||
|
|
4f34ca783b | ||
|
|
bae8dbe083 | ||
|
|
d1bca362de | ||
|
|
d5137e393d | ||
|
|
20caf7ef5b | ||
|
|
e8e01a706e | ||
|
|
2f8c98003d | ||
|
|
02701db815 | ||
|
|
b7f17f2d14 | ||
|
|
cf1d5e7324 | ||
|
|
a40bf5840d | ||
|
|
32f7bc053a | ||
|
|
a006b07be5 | ||
|
|
a44303a370 | ||
|
|
d69a56e2d5 | ||
|
|
08e1bb2e22 | ||
|
|
29c15116bc | ||
|
|
8e96d3e972 | ||
|
|
bf0d98c69c | ||
|
|
73d4ac1eb1 | ||
|
|
e832c7661a | ||
|
|
b962bc9227 | ||
|
|
6d14dce668 | ||
|
|
1ff220f0d8 | ||
|
|
2e77a76c7b | ||
|
|
bf5b544ca3 | ||
|
|
546f75d3a6 | ||
|
|
bf40f792c2 | ||
|
|
8a9392e400 | ||
|
|
14bfaff30b | ||
|
|
b32c334a71 | ||
|
|
2aa17513e1 | ||
|
|
ba243b28a9 | ||
|
|
1bb65f420c | ||
|
|
23a99758d2 | ||
|
|
139f8e2d13 | ||
|
|
ac1a582ecf | ||
|
|
6b168f36ec | ||
|
|
50b647e26e | ||
|
|
57776b7c7d |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
11
.cursor/mcp.json
Normal file
11
.cursor/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
106
.cursor/skills/medialibrary-development/SKILL.md
Normal file
106
.cursor/skills/medialibrary-development/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: medialibrary-development
|
||||
description: Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Spatie
|
||||
---
|
||||
|
||||
# Media Library Development
|
||||
|
||||
## Overview
|
||||
|
||||
Use spatie/laravel-medialibrary to associate files with Eloquent models. Supports image/video conversions, responsive images, multiple collections, and various storage disks.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Activate when working with file uploads, media attachments, or image processing in Laravel.
|
||||
- Activate when code references `HasMedia`, `InteractsWithMedia`, the `Media` model, or media collections/conversions.
|
||||
- Activate when the user wants to add, retrieve, convert, or manage files attached to Eloquent models.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: media uploads, collections, conversions, responsive images, custom properties, file retrieval, path/URL generation.
|
||||
- Out of scope: general file storage without Eloquent association, non-Laravel frameworks.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the task (model setup, adding media, defining conversions, retrieving files, etc.).
|
||||
2. Read `references/medialibrary-guide.md` and focus on the relevant section.
|
||||
3. Apply the patterns from the reference, keeping code minimal and Laravel-native.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Model Setup
|
||||
|
||||
Every model that should have media must implement `HasMedia` and use the `InteractsWithMedia` trait:
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Media
|
||||
|
||||
```php
|
||||
$blogPost->addMedia($file)->toMediaCollection('images');
|
||||
$blogPost->addMediaFromUrl($url)->toMediaCollection('images');
|
||||
$blogPost->addMediaFromRequest('file')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Defining Collections
|
||||
|
||||
```php
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('avatar')->singleFile();
|
||||
$this->addMediaCollection('downloads')->useDisk('s3');
|
||||
}
|
||||
```
|
||||
|
||||
### Defining Conversions
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300)
|
||||
->nonQueued();
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving Media
|
||||
|
||||
```php
|
||||
$url = $model->getFirstMediaUrl('images');
|
||||
$thumbUrl = $model->getFirstMediaUrl('images', 'thumb');
|
||||
$allMedia = $model->getMedia('images');
|
||||
```
|
||||
|
||||
## Do and Don't
|
||||
|
||||
Do:
|
||||
- Always implement the `HasMedia` interface alongside the `InteractsWithMedia` trait.
|
||||
- Use `?Media $media = null` as the parameter for `registerMediaConversions()`.
|
||||
- Call `->toMediaCollection()` to finalize adding media.
|
||||
- Use `->nonQueued()` for conversions that should run synchronously.
|
||||
- Use `->singleFile()` on collections that should only hold one file.
|
||||
- Use `Spatie\Image\Enums\Fit` enum values for fit methods.
|
||||
|
||||
Don't:
|
||||
- Don't forget to run `php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"` before migrating.
|
||||
- Don't use `env()` for disk configuration; use `config()` or set it in `config/media-library.php`.
|
||||
- Don't call `addMedia()` without calling `toMediaCollection()` — the media won't be saved.
|
||||
- Don't reference conversion names that aren't registered in `registerMediaConversions()`.
|
||||
|
||||
## References
|
||||
|
||||
- `references/medialibrary-guide.md`
|
||||
@@ -0,0 +1,577 @@
|
||||
# Laravel Media Library Reference
|
||||
|
||||
Complete reference for `spatie/laravel-medialibrary`. Full documentation: https://spatie.be/docs/laravel-medialibrary
|
||||
|
||||
## Model Setup
|
||||
|
||||
Implement `HasMedia` and use `InteractsWithMedia`:
|
||||
|
||||
```php
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('images');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Media
|
||||
|
||||
### From uploaded file
|
||||
|
||||
```php
|
||||
$model->addMedia($request->file('image'))->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From request (shorthand)
|
||||
|
||||
```php
|
||||
$model->addMediaFromRequest('image')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From URL
|
||||
|
||||
```php
|
||||
$model->addMediaFromUrl('https://example.com/image.jpg')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From string content
|
||||
|
||||
```php
|
||||
$model->addMediaFromString('raw content')->usingFileName('file.txt')->toMediaCollection('files');
|
||||
```
|
||||
|
||||
### From base64
|
||||
|
||||
```php
|
||||
$model->addMediaFromBase64($base64Data)->usingFileName('photo.jpg')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From stream
|
||||
|
||||
```php
|
||||
$model->addMediaFromStream($stream)->usingFileName('file.pdf')->toMediaCollection('files');
|
||||
```
|
||||
|
||||
### From existing disk
|
||||
|
||||
```php
|
||||
$model->addMediaFromDisk('path/to/file.jpg', 's3')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Multiple files from request
|
||||
|
||||
```php
|
||||
$model->addMultipleMediaFromRequest(['images'])->each(function ($fileAdder) {
|
||||
$fileAdder->toMediaCollection('images');
|
||||
});
|
||||
|
||||
$model->addAllMediaFromRequest()->each(function ($fileAdder) {
|
||||
$fileAdder->toMediaCollection('images');
|
||||
});
|
||||
```
|
||||
|
||||
### Copy instead of move
|
||||
|
||||
```php
|
||||
$model->copyMedia($pathToFile)->toMediaCollection('images');
|
||||
// or
|
||||
$model->addMedia($pathToFile)->preservingOriginal()->toMediaCollection('images');
|
||||
```
|
||||
|
||||
## FileAdder Options
|
||||
|
||||
All methods are chainable before calling `toMediaCollection()`:
|
||||
|
||||
```php
|
||||
$model->addMedia($file)
|
||||
->usingName('Custom Name') // display name
|
||||
->usingFileName('custom-name.jpg') // filename on disk
|
||||
->setOrder(3) // order within collection
|
||||
->withCustomProperties(['alt' => 'A landscape photo'])
|
||||
->withManipulations(['thumb' => ['filter' => 'greyscale']])
|
||||
->withResponsiveImages() // generate responsive variants
|
||||
->storingConversionsOnDisk('s3') // put conversions on different disk
|
||||
->addCustomHeaders(['CacheControl' => 'max-age=31536000'])
|
||||
->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Store on cloud disk
|
||||
|
||||
```php
|
||||
$model->addMedia($file)->toMediaCollectionOnCloudDisk('images');
|
||||
```
|
||||
|
||||
## Media Collections
|
||||
|
||||
Define in `registerMediaCollections()`:
|
||||
|
||||
```php
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
// Basic collection
|
||||
$this->addMediaCollection('images');
|
||||
|
||||
// Single file (replacing previous on new upload)
|
||||
$this->addMediaCollection('avatar')
|
||||
->singleFile();
|
||||
|
||||
// Keep only latest N items
|
||||
$this->addMediaCollection('recent_photos')
|
||||
->onlyKeepLatest(5);
|
||||
|
||||
// Specific disk
|
||||
$this->addMediaCollection('downloads')
|
||||
->useDisk('s3');
|
||||
|
||||
// With conversions disk
|
||||
$this->addMediaCollection('photos')
|
||||
->useDisk('s3')
|
||||
->storeConversionsOnDisk('s3-thumbnails');
|
||||
|
||||
// MIME type restriction
|
||||
$this->addMediaCollection('documents')
|
||||
->acceptsMimeTypes(['application/pdf', 'application/zip']);
|
||||
|
||||
// Custom validation
|
||||
$this->addMediaCollection('images')
|
||||
->acceptsFile(function ($file) {
|
||||
return $file->mimeType === 'image/jpeg';
|
||||
});
|
||||
|
||||
// Fallback URL/path when collection is empty
|
||||
$this->addMediaCollection('avatar')
|
||||
->singleFile()
|
||||
->useFallbackUrl('/images/default-avatar.jpg')
|
||||
->useFallbackPath(public_path('/images/default-avatar.jpg'));
|
||||
|
||||
// Enable responsive images for entire collection
|
||||
$this->addMediaCollection('hero_images')
|
||||
->withResponsiveImages();
|
||||
|
||||
// Collection-specific conversions
|
||||
$this->addMediaCollection('photos')
|
||||
->registerMediaConversions(function () {
|
||||
$this->addMediaConversion('card')
|
||||
->fit(Fit::Crop, 400, 400);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Media Conversions
|
||||
|
||||
Define in `registerMediaConversions()`:
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300)
|
||||
->nonQueued();
|
||||
|
||||
$this->addMediaConversion('preview')
|
||||
->fit(Fit::Crop, 500, 500)
|
||||
->withResponsiveImages()
|
||||
->queued();
|
||||
|
||||
$this->addMediaConversion('banner')
|
||||
->fit(Fit::Max, 1200, 630)
|
||||
->performOnCollections('images', 'headers')
|
||||
->nonQueued()
|
||||
->sharpen(10);
|
||||
|
||||
// Conditional conversion based on media properties
|
||||
if ($media?->mime_type === 'image/png') {
|
||||
$this->addMediaConversion('png-thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
|
||||
// Keep original format instead of converting to jpg
|
||||
$this->addMediaConversion('web')
|
||||
->fit(Fit::Max, 800, 800)
|
||||
->keepOriginalImageFormat();
|
||||
|
||||
// PDF page rendering
|
||||
$this->addMediaConversion('pdf-preview')
|
||||
->pdfPageNumber(1)
|
||||
->fit(Fit::Contain, 400, 400);
|
||||
|
||||
// Video frame extraction
|
||||
$this->addMediaConversion('video-thumb')
|
||||
->extractVideoFrameAtSecond(5)
|
||||
->fit(Fit::Crop, 300, 300);
|
||||
}
|
||||
```
|
||||
|
||||
### Image Manipulation Methods (via spatie/image)
|
||||
|
||||
Resizing and fitting:
|
||||
- `width(int)`, `height(int)` — constrain dimensions
|
||||
- `fit(Fit, int, int)` — fit within bounds using `Fit::Contain`, `Fit::Max`, `Fit::Fill`, `Fit::Stretch`, `Fit::Crop`
|
||||
- `crop(int, int)` — crop to exact dimensions
|
||||
|
||||
Effects:
|
||||
- `sharpen(int)`, `blur(int)`, `pixelate(int)`
|
||||
- `greyscale()`, `sepia()`
|
||||
- `brightness(int)`, `contrast(int)`, `colorize(int, int, int)`
|
||||
|
||||
Orientation:
|
||||
- `orientation(int)`, `flip(string)`, `rotate(int)`
|
||||
|
||||
Format:
|
||||
- `format(string)` — `'jpg'`, `'png'`, `'webp'`, `'avif'`
|
||||
- `quality(int)` — 1-100
|
||||
|
||||
Other:
|
||||
- `border(int, string, string)`, `watermark(string)`
|
||||
- `optimize()`, `nonOptimized()`
|
||||
|
||||
### Conversion Configuration
|
||||
|
||||
- `performOnCollections('col1', 'col2')` — limit to specific collections
|
||||
- `queued()` / `nonQueued()` — run async or sync
|
||||
- `withResponsiveImages()` — also generate responsive variants for this conversion
|
||||
- `keepOriginalImageFormat()` — preserve png/webp/gif instead of converting to jpg
|
||||
- `pdfPageNumber(int)` — which PDF page to render
|
||||
- `extractVideoFrameAtSecond(int)` — video thumbnail timing
|
||||
|
||||
## Retrieving Media
|
||||
|
||||
### Getting media items
|
||||
|
||||
```php
|
||||
$media = $model->getMedia('images'); // all in collection
|
||||
$first = $model->getFirstMedia('images'); // first item
|
||||
$last = $model->getLastMedia('images'); // last item
|
||||
$has = $model->hasMedia('images'); // boolean check
|
||||
```
|
||||
|
||||
### Getting URLs
|
||||
|
||||
```php
|
||||
$url = $model->getFirstMediaUrl('images'); // original URL
|
||||
$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); // conversion URL
|
||||
$lastUrl = $model->getLastMediaUrl('images', 'thumb');
|
||||
```
|
||||
|
||||
### Getting paths
|
||||
|
||||
```php
|
||||
$path = $model->getFirstMediaPath('images');
|
||||
$thumbPath = $model->getFirstMediaPath('images', 'thumb');
|
||||
```
|
||||
|
||||
### Temporary URLs (S3)
|
||||
|
||||
```php
|
||||
$tempUrl = $model->getFirstTemporaryUrl(
|
||||
now()->addMinutes(30),
|
||||
'images',
|
||||
'thumb'
|
||||
);
|
||||
```
|
||||
|
||||
### Fallback URLs
|
||||
|
||||
```php
|
||||
$url = $model->getFallbackMediaUrl('avatar');
|
||||
```
|
||||
|
||||
### From the Media model
|
||||
|
||||
```php
|
||||
$media = $model->getFirstMedia('images');
|
||||
|
||||
$media->getUrl(); // original URL
|
||||
$media->getUrl('thumb'); // conversion URL
|
||||
$media->getPath(); // disk path
|
||||
$media->getFullUrl(); // full URL with domain
|
||||
$media->getTemporaryUrl(now()->addMinutes(30));
|
||||
$media->hasGeneratedConversion('thumb'); // check if conversion exists
|
||||
```
|
||||
|
||||
### Filtering media
|
||||
|
||||
```php
|
||||
$media = $model->getMedia('images', function (Media $media) {
|
||||
return $media->getCustomProperty('featured') === true;
|
||||
});
|
||||
|
||||
$media = $model->getMedia('images', ['mime_type' => 'image/jpeg']);
|
||||
```
|
||||
|
||||
## Custom Properties
|
||||
|
||||
Store arbitrary metadata on media items:
|
||||
|
||||
```php
|
||||
// When adding
|
||||
$model->addMedia($file)
|
||||
->withCustomProperties([
|
||||
'alt' => 'Descriptive text',
|
||||
'credits' => 'Photographer Name',
|
||||
])
|
||||
->toMediaCollection('images');
|
||||
|
||||
// Get/set on existing media
|
||||
$media->setCustomProperty('alt', 'Updated text');
|
||||
$media->save();
|
||||
|
||||
$alt = $media->getCustomProperty('alt');
|
||||
$has = $media->hasCustomProperty('alt');
|
||||
$media->forgetCustomProperty('alt');
|
||||
$media->save();
|
||||
```
|
||||
|
||||
## Responsive Images
|
||||
|
||||
Generate multiple sizes for optimal loading:
|
||||
|
||||
```php
|
||||
// On the FileAdder
|
||||
$model->addMedia($file)
|
||||
->withResponsiveImages()
|
||||
->toMediaCollection('images');
|
||||
|
||||
// On a conversion
|
||||
$this->addMediaConversion('hero')
|
||||
->fit(Fit::Max, 1200, 800)
|
||||
->withResponsiveImages();
|
||||
|
||||
// On a collection
|
||||
$this->addMediaCollection('photos')
|
||||
->withResponsiveImages();
|
||||
```
|
||||
|
||||
### Using in Blade
|
||||
|
||||
```blade
|
||||
{{-- Renders img tag with srcset --}}
|
||||
{{ $media->toHtml() }}
|
||||
|
||||
{{-- With attributes --}}
|
||||
{{ $media->img()->attributes(['class' => 'w-full', 'alt' => 'Photo']) }}
|
||||
|
||||
{{-- Get srcset string --}}
|
||||
<img src="{{ $media->getUrl() }}" srcset="{{ $media->getSrcset() }}" />
|
||||
|
||||
{{-- Responsive conversion --}}
|
||||
<img src="{{ $media->getUrl('hero') }}" srcset="{{ $media->getSrcset('hero') }}" />
|
||||
```
|
||||
|
||||
### Placeholder SVG
|
||||
|
||||
```php
|
||||
$svg = $media->responsiveImages()->getPlaceholderSvg(); // tiny blurred base64 placeholder
|
||||
```
|
||||
|
||||
## Managing Media
|
||||
|
||||
### Clear a collection
|
||||
|
||||
```php
|
||||
$model->clearMediaCollection('images');
|
||||
```
|
||||
|
||||
### Clear except specific items
|
||||
|
||||
```php
|
||||
$model->clearMediaCollectionExcept('images', $mediaToKeep);
|
||||
```
|
||||
|
||||
### Delete specific media
|
||||
|
||||
```php
|
||||
$model->deleteMedia($mediaId);
|
||||
```
|
||||
|
||||
### Delete all media
|
||||
|
||||
```php
|
||||
$model->deleteAllMedia();
|
||||
```
|
||||
|
||||
### Delete model but keep media files
|
||||
|
||||
```php
|
||||
$model->deletePreservingMedia();
|
||||
```
|
||||
|
||||
### Reorder media
|
||||
|
||||
```php
|
||||
Media::setNewOrder([3, 1, 2]); // media IDs in desired order
|
||||
```
|
||||
|
||||
### Move/copy media between models
|
||||
|
||||
```php
|
||||
$media->move($otherModel, 'images');
|
||||
$media->copy($otherModel, 'images');
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Events\MediaHasBeenAddedEvent;
|
||||
use Spatie\MediaLibrary\Conversions\Events\ConversionWillStartEvent;
|
||||
use Spatie\MediaLibrary\Conversions\Events\ConversionHasBeenCompletedEvent;
|
||||
use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent;
|
||||
```
|
||||
|
||||
Listen to these events to hook into the media lifecycle:
|
||||
```php
|
||||
Event::listen(MediaHasBeenAddedEvent::class, function ($event) {
|
||||
$event->media; // the added Media model
|
||||
});
|
||||
|
||||
Event::listen(ConversionHasBeenCompletedEvent::class, function ($event) {
|
||||
$event->media;
|
||||
$event->conversion;
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Key `config/media-library.php` options:
|
||||
|
||||
```php
|
||||
return [
|
||||
'disk_name' => 'public', // default disk
|
||||
'max_file_size' => 1024 * 1024 * 10, // 10MB
|
||||
'queue_connection_name' => '', // queue connection
|
||||
'queue_name' => '', // queue name
|
||||
'queue_conversions_by_default' => true, // queue conversions
|
||||
'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
|
||||
'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class,
|
||||
'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class,
|
||||
'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class,
|
||||
'image_driver' => 'gd', // 'gd', 'imagick', or 'vips'
|
||||
'image_optimizers' => [/* optimizer config */],
|
||||
'version_urls' => true, // cache busting
|
||||
'default_loading_attribute_value' => null, // 'lazy' for lazy loading
|
||||
];
|
||||
```
|
||||
|
||||
### Custom Path Generator
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
|
||||
|
||||
class CustomPathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
return md5($media->id) . '/';
|
||||
}
|
||||
|
||||
public function getPathForConversions(Media $media): string
|
||||
{
|
||||
return $this->getPath($media) . 'conversions/';
|
||||
}
|
||||
|
||||
public function getPathForResponsiveImages(Media $media): string
|
||||
{
|
||||
return $this->getPath($media) . 'responsive/';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom File Namer
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\FileNamer\FileNamer;
|
||||
|
||||
class CustomFileNamer extends FileNamer
|
||||
{
|
||||
public function originalFileName(string $fileName): string
|
||||
{
|
||||
return Str::slug(pathinfo($fileName, PATHINFO_FILENAME));
|
||||
}
|
||||
|
||||
public function conversionFileName(string $fileName, Conversion $conversion): string
|
||||
{
|
||||
return $this->originalFileName($fileName) . '-' . $conversion->getName();
|
||||
}
|
||||
|
||||
public function responsiveFileName(string $fileName): string
|
||||
{
|
||||
return pathinfo($fileName, PATHINFO_FILENAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Media Model
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia;
|
||||
|
||||
class Media extends BaseMedia
|
||||
{
|
||||
// Add custom methods, scopes, or override behavior
|
||||
}
|
||||
```
|
||||
|
||||
Register in config: `'media_model' => App\Models\Media::class`
|
||||
|
||||
## Downloading Media
|
||||
|
||||
### Single file
|
||||
|
||||
```php
|
||||
return $media->toResponse($request); // download
|
||||
return $media->toInlineResponse($request); // display inline
|
||||
return $media->stream(); // stream
|
||||
```
|
||||
|
||||
### ZIP download of collection
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\MediaStream;
|
||||
|
||||
return MediaStream::create('photos.zip')
|
||||
->addMedia($model->getMedia('images'));
|
||||
```
|
||||
|
||||
## Using with API Resources
|
||||
|
||||
```php
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'image' => $this->getFirstMediaUrl('images'),
|
||||
'thumb' => $this->getFirstMediaUrl('images', 'thumb'),
|
||||
'media' => $this->getMedia('images')->map(function ($media) {
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'url' => $media->getUrl(),
|
||||
'thumb' => $media->getUrl('thumb'),
|
||||
'name' => $media->name,
|
||||
'size' => $media->size,
|
||||
'type' => $media->mime_type,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
157
.cursor/skills/pest-testing/SKILL.md
Normal file
157
.cursor/skills/pest-testing/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```php
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```php
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
```
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```php
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
```
|
||||
|
||||
## Pest 4 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```php
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```php
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Test Sharding
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```php
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
119
.cursor/skills/tailwindcss-development/SKILL.md
Normal file
119
.cursor/skills/tailwindcss-development/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM adminer:latest
|
||||
|
||||
USER root
|
||||
|
||||
RUN set -x && \
|
||||
apt update && \
|
||||
apt install curl -y && \
|
||||
cd /var/www/html/plugins-enabled && \
|
||||
curl -O https://gist.githubusercontent.com/gdarko/00af6e9a754f09c3f81cd3c606c33311/raw/d5f6a30f00edecf30a5d380340d9dae79a3b7352/login-password-less.php
|
||||
|
||||
USER adminer
|
||||
CMD [ "php", "-S", "[::]:8080", "-t", "/var/www/html" ]
|
||||
|
||||
EXPOSE 8080
|
||||
@@ -1,22 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /home/invoiceshelf/app/public;
|
||||
index index.php;
|
||||
|
||||
error_log /var/log/nginx/dev-error.log;
|
||||
access_log /var/log/nginx/dev-access.log;
|
||||
|
||||
server_name invoiceshelf.test;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass php-fpm:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
FROM php:8.3-fpm-bookworm
|
||||
|
||||
ARG UID
|
||||
ARG GID
|
||||
|
||||
ENV UID=${UID}
|
||||
ENV GID=${GID}
|
||||
|
||||
USER root
|
||||
|
||||
# Create user/group
|
||||
RUN addgroup --gid ${GID} --system invoiceshelf && \
|
||||
adduser --gid ${GID} --system --disabled-password --shell /bin/sh -u ${UID} --home /home/invoiceshelf invoiceshelf && \
|
||||
sed -i "s/user = www-data/user = invoiceshelf/g" /usr/local/etc/php-fpm.d/www.conf && \
|
||||
sed -i "s/group = www-data/group = invoiceshelf/g" /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Install composer & npm
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt install -y nodejs
|
||||
|
||||
# install dependencies
|
||||
RUN apt update && apt install -y \
|
||||
libpng-dev \
|
||||
zlib1g-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
libonig-dev \
|
||||
libpq-dev \
|
||||
sqlite3 \
|
||||
postgresql-client \
|
||||
mariadb-client \
|
||||
zip \
|
||||
curl \
|
||||
unzip \
|
||||
webp \
|
||||
&& docker-php-ext-configure gd \
|
||||
&& docker-php-ext-install -j$(nproc) gd \
|
||||
&& docker-php-ext-install bcmath \
|
||||
&& docker-php-ext-install mbstring \
|
||||
&& docker-php-ext-install mysqli \
|
||||
&& docker-php-ext-install pdo_mysql \
|
||||
&& docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \
|
||||
&& docker-php-ext-install pgsql \
|
||||
&& docker-php-ext-install pdo_pgsql \
|
||||
&& docker-php-ext-install zip \
|
||||
&& docker-php-ext-install xml \
|
||||
&& docker-php-ext-install exif \
|
||||
&& docker-php-source delete
|
||||
|
||||
# Clear cache
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set workdir
|
||||
WORKDIR /home/invoiceshelf/app
|
||||
|
||||
# Copy Files
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Launch php-fpm
|
||||
CMD ["php-fpm"]
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "############################################"
|
||||
echo "### InvoiceShelf Development Environment ###"
|
||||
echo "############################################"
|
||||
|
||||
cd /home/invoiceshelf/app
|
||||
|
||||
|
||||
# Composer build
|
||||
if [ ! -d vendor ]; then
|
||||
composer install
|
||||
fi
|
||||
|
||||
# Empty sqlite database
|
||||
if [ ! -f database/database.sqlite ]; then
|
||||
cp database/stubs/sqlite.empty.db database/database.sqlite
|
||||
fi
|
||||
|
||||
# .env file set up
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force
|
||||
fi
|
||||
|
||||
# NPM build
|
||||
if [ ! -d node_modules ]; then
|
||||
npm install
|
||||
npm run build
|
||||
fi
|
||||
|
||||
# Storage symlink
|
||||
php artisan storage:link
|
||||
|
||||
# Permissions
|
||||
chmod 775 storage/framework
|
||||
chmod 775 storage/logs
|
||||
chmod 775 bootstrap/cache
|
||||
chown -R ${UID}:${GID} /home/invoiceshelf/app
|
||||
chmod +x artisan
|
||||
|
||||
echo "Entrypoint complete."
|
||||
|
||||
exec $@
|
||||
1974
.dev/php/php.ini
1974
.dev/php/php.ini
File diff suppressed because it is too large
Load Diff
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
||||
.idea/
|
||||
.git/
|
||||
|
||||
.github/
|
||||
docker/
|
||||
!docker/production/entrypoint.d
|
||||
!docker/production/inject.sh
|
||||
|
||||
node_modules/
|
||||
|
||||
database/*.sqlite
|
||||
storage/app/*
|
||||
!storage/app/templates*
|
||||
storage/fonts/*
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
|
||||
tests/
|
||||
vendor/
|
||||
|
||||
.dockerignore
|
||||
.devenvconfig
|
||||
.editorconfig
|
||||
.env
|
||||
.env.testing
|
||||
.eslintrc.mjs
|
||||
.gitattributes
|
||||
.gitignore
|
||||
.prettierrc.json
|
||||
devenv
|
||||
|
||||
CODE_OF_CONDUCT.md
|
||||
Dockerfile
|
||||
*.Dockerfile
|
||||
LICENSE
|
||||
Makefile
|
||||
SECURITY.md
|
||||
_ide_helper.php
|
||||
crowdin.yml
|
||||
invoiceshelf.code-workspace
|
||||
package-lock.json
|
||||
phpunit.xml
|
||||
readme.md
|
||||
52
.env.example
52
.env.example
@@ -1,19 +1,11 @@
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_KEY=base64:kgk/4DW1vEVy7aEvet5FPp5un6PIGe/so8H0mvoUtW0=
|
||||
APP_DEBUG=true
|
||||
|
||||
APP_NAME="InvoiceShelf"
|
||||
APP_LOG_LEVEL=debug
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://invoiceshelf.test
|
||||
|
||||
APP_URL=
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_HOST=
|
||||
@@ -22,35 +14,15 @@ DB_DATABASE=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
CACHE_STORE=file
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=1440
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_ENCRYPTION=
|
||||
|
||||
MAIL_FROM_NAME=
|
||||
MAIL_FROM_ADDRESS=
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_KEY=
|
||||
PUSHER_SECRET=
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=invoiceshelf.test
|
||||
SANCTUM_STATEFUL_DOMAIN=
|
||||
TRUSTED_PROXIES="*"
|
||||
|
||||
CRON_JOB_AUTH_TOKEN=""
|
||||
LOG_STACK=single
|
||||
# Dompdf: keep false so untrusted HTML in PDF notes cannot trigger outbound requests (SSRF).
|
||||
# Set true only if you fully trust all PDF HTML and need remote images/CSS.
|
||||
DOMPDF_ENABLE_REMOTE=false
|
||||
|
||||
# InvoiceShelf marketplace and updater base URL.
|
||||
# Defaults to https://invoiceshelf.com. Override to point at a local website
|
||||
# checkout for development:
|
||||
# INVOICESHELF_BASE_URL=http://invoiceshelf-website.test
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_DEBUG=true
|
||||
APP_KEY=base64:IdDlpLmYyWA9z4Ruj5st1FSYrhCR7lPOscLGCz2Jf4I=
|
||||
DB_CONNECTION=sqlite
|
||||
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.mailtrap.io
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=ff538f0e1037f4
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// .eslintrc.js
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
// add more generic rulesets here, such as:
|
||||
// 'eslint:recommended',
|
||||
"plugin:vue/vue3-recommended",
|
||||
"prettier",
|
||||
],
|
||||
rules: {
|
||||
// override/add rules settings here, such as:
|
||||
// 'vue/no-unused-vars': 'error'
|
||||
},
|
||||
};
|
||||
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
Thank you for investing your time in contributing to our project! :sparkles:.
|
||||
|
||||
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
|
||||
Read our [Code of Conduct](../CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
|
||||
|
||||
In this guide you will get an overview of the contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR.
|
||||
|
||||
@@ -39,7 +39,7 @@ Scan through our [existing issues](https://github.com/InvoiceShelf/InvoiceShelf/
|
||||
- Using the command line:
|
||||
- [Fork the repo](https://docs.github.com/en/get-started/quickstart/fork-a-repo) so that you can make your changes without affecting the original project until you're ready to merge them.
|
||||
|
||||
2. Install or update to **Node.js**, at the version specified in `.node-version`. For more information, see [the development guide](../contributing/development.md).
|
||||
2. Install or update to **Node.js**, at the version specified in `.node-version`. For more information, see [the development guide](../docker/development/README.md).
|
||||
|
||||
3. Create a working branch and start with your changes!
|
||||
|
||||
|
||||
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: Bug report
|
||||
description: Template for bug reports
|
||||
labels:
|
||||
- bug
|
||||
- triage
|
||||
projects: ["InvoiceShelf/2"]
|
||||
labels: ['bug', 'triage']
|
||||
type: Bug
|
||||
projects: ['InvoiceShelf/2']
|
||||
assignees:
|
||||
- rihards-simanovics
|
||||
|
||||
@@ -13,15 +12,15 @@ body:
|
||||
- type: checkboxes
|
||||
id: confirm-read-documentation
|
||||
attributes:
|
||||
label: Issue filing pre-requisites
|
||||
description: "Prior to filing an issue please confirm that:"
|
||||
label: Issue filing prerequisites
|
||||
description: 'Prior to filing an issue please confirm that:'
|
||||
options:
|
||||
- label: I've checked the documentation.
|
||||
- label: I've checked the [documentation](https://docs.invoiceshelf.com/).
|
||||
required: true
|
||||
- label: I've looked for similar issues both Open and Closed.
|
||||
required: true
|
||||
- label: >-
|
||||
Ive tried clearing both cache and cookies in my browser or tried
|
||||
I've tried clearing both cache and cookies in my browser or tried
|
||||
opening the app in the Incognito/InPrivate window.
|
||||
required: true
|
||||
|
||||
@@ -29,19 +28,19 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >-
|
||||
If you did all of the above, we first would like to thank you for taking
|
||||
the time to fill out this bug report, it will help us tremendously to
|
||||
If you did all of the above, we would first like to thank you for taking
|
||||
the time to fill out this bug report. It will help us tremendously to
|
||||
find and fix the issue sooner!
|
||||
|
||||
# Description fo the bug
|
||||
# Description of the bug
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: >-
|
||||
When doing `x`, `y`, and `z` in that order, an error occurs, but when x,
|
||||
z and then y is done, the error does not occur.
|
||||
When doing `x`, `y`, and `z` in that order, an error occurs, but when `x`,
|
||||
`z` and then `y` is done, the error does not occur.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -53,34 +52,41 @@ body:
|
||||
description: A clear step-by-step explanation of how to reproduce your issue
|
||||
placeholder: >-
|
||||
## Produces an error:
|
||||
|
||||
1. Do X;
|
||||
|
||||
2. Do Y;
|
||||
|
||||
3. Do Z;
|
||||
|
||||
|
||||
## Works fine:
|
||||
|
||||
4. Do X;
|
||||
|
||||
5. Do Z;
|
||||
|
||||
6. Do Y.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
# Expected Behaviour
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
id: expected-behaviour
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
label: Expected behaviour
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: doing `x`, `y`, and `z` in that order should not generate an error.
|
||||
placeholder: Doing `x`, `y`, and `z` in that order should not generate an error.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
# Actual Behaviour
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
id: actual-behaviour
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: A clear and concise description of what actually happen.
|
||||
placeholder: doing `x`, `y`, and `z` in that order generate an error.
|
||||
label: Actual behaviour
|
||||
description: A clear and concise description of what actually happens.
|
||||
placeholder: Doing `x`, `y`, and `z` in that order generates an error.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -88,9 +94,19 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |-
|
||||
## Envirment
|
||||
## Environment
|
||||
|
||||
Now lets collect some information about your environment.
|
||||
Now let's collect some information about your environment.
|
||||
|
||||
# Use Docker?
|
||||
- type: checkboxes
|
||||
id: confirm-docker-install
|
||||
attributes:
|
||||
label: Docker
|
||||
description: 'Please note that unless the issue is with the app itself, you should file a bug report under the [docker](https://github.com/InvoiceShelf/docker) repository.'
|
||||
options:
|
||||
- label: App running in Docker Container.
|
||||
- label: Docker container running behind Reverse proxy.
|
||||
|
||||
# App Version
|
||||
- type: input
|
||||
@@ -115,7 +131,7 @@ body:
|
||||
id: database-type
|
||||
attributes:
|
||||
label: Database type
|
||||
placeholder: MariaDB / MySQL / PostreSQL / SQLite
|
||||
placeholder: MariaDB / MySQL / PostgreSQL / SQLite
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -133,14 +149,14 @@ body:
|
||||
id: web-browser
|
||||
attributes:
|
||||
label: Web Browser
|
||||
placeholder: "Firefox / Safari / or any other (chromium based browser)"
|
||||
placeholder: 'Firefox / Safari / or any other (Chromium-based browser)'
|
||||
|
||||
# OS Version
|
||||
- type: input
|
||||
id: server-os
|
||||
attributes:
|
||||
label: Server OS
|
||||
description: If Linux please make sure to provide both distro name and it's version
|
||||
description: If Linux, please make sure to provide both the distro name and its version
|
||||
placeholder: Windows / Linux (e.g. Ubuntu 24.04)
|
||||
|
||||
# Associated Logs
|
||||
@@ -150,7 +166,7 @@ body:
|
||||
## Logs
|
||||
|
||||
|
||||
last but not least, could you please take some time to get us the logs
|
||||
Last but not least, could you please take some time to get us the logs
|
||||
for:
|
||||
|
||||
# Log Reverse proxy
|
||||
@@ -162,21 +178,17 @@ body:
|
||||
Please provide logs from your Apache, Nginx, Traefik, or any other
|
||||
reverse proxy application.
|
||||
placeholder: >-
|
||||
[2015-02-04 10:00:00] production.INFO: Laravel 5.0 released. The world rejoices.
|
||||
[2015-12-03 12:00:00] production.INFO: PHP 7.0 introduced scalar type declarations. Minds blown.
|
||||
[2016-05-15 14:00:00] production.INFO: Composer installed new dependencies. Dependency hell avoided.
|
||||
[2017-08-20 16:00:00] production.INFO: Artisan command executed: migrate. Smooth sailing.
|
||||
[2018-01-10 09:00:00] production.INFO: User login successful.
|
||||
[2018-01-10 09:05:00] production.ERROR: Database connection failed. Did you try turning it off and on again?
|
||||
[2018-01-10 09:10:00] production.WARNING: Deprecated function used in UserController.php. Time to refactor, again.
|
||||
[2018-01-10 09:15:00] production.ERROR: Uncaught Exception: Division by zero. Oops, math is hard.
|
||||
[2018-01-10 09:20:00] production.INFO: User logout successful. See you later, alligator.
|
||||
[2019-03-25 11:00:00] production.INFO: Cache cleared. Fresh start!
|
||||
[2019-06-30 13:00:00] production.ERROR: Syntax error. Missing semicolon strikes again.
|
||||
[2020-09-10 15:00:00] production.INFO: User registered. Welcome aboard!
|
||||
[2021-11-05 17:00:00] production.WARNING: Low disk space. Time to clean up.
|
||||
[2022-12-20 19:00:00] production.INFO: Server rebooted. All systems go.
|
||||
[2024-11-14 18:56:20] production.INFO: Unexpected item in the bagging area. Please wait for assistance.
|
||||
2023-04-12 10:15:32 [NGINX] 172.16.0.5 - - [12/Apr/2023:10:15:32 +0000] "GET /index.html HTTP/1.1" 200 1024 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
|
||||
2023-04-12 10:15:33 [NGINX] 172.16.0.7 - - [12/Apr/2023:10:15:33 +0000] "POST /login HTTP/1.1" 302 - "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8"
|
||||
|
||||
2023-04-12 10:15:34 [NGINX] 172.16.0.9 - - [12/Apr/2023:10:15:34 +0000] "GET /about HTTP/1.1" 200 2048 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
|
||||
|
||||
2023-04-12 10:15:35 [NGINX] 172.16.0.11 - - [12/Apr/2023:10:15:35 +0000] "PUT /api/v1/users/123 HTTP/1.1" 200 - "https://example.com/profile" "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
2023-04-12 10:15:36 [NGINX] 172.16.0.13 - - [12/Apr/2023:10:15:36 +0000] "DELETE /api/v1/products/456 HTTP/1.1" 204 - "-" "Dalvik/2.1.0 (Linux; U; Android 10; Pixel 4 Build/QD1A.190821.014.C2)"
|
||||
|
||||
2023-04-12 10:15:37 [NGINX] 172.16.0.15 - - [12/Apr/2023:10:15:37 +0000] "GET /easter-egg HTTP/1.1" 200 42 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.3"
|
||||
render: irc logs
|
||||
|
||||
# PHP/Laravel Logs
|
||||
@@ -186,12 +198,35 @@ body:
|
||||
label: Laravel/PHP logs
|
||||
description: Please provide logs from either PHP or Laravel or both.
|
||||
placeholder: >-
|
||||
2023-04-12 10:15:32 [NGINX] 172.16.0.5 - - [12/Apr/2023:10:15:32 +0000] "GET /index.html HTTP/1.1" 200 1024 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
2023-04-12 10:15:33 [NGINX] 172.16.0.7 - - [12/Apr/2023:10:15:33 +0000] "POST /login HTTP/1.1" 302 - "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8"
|
||||
2023-04-12 10:15:34 [NGINX] 172.16.0.9 - - [12/Apr/2023:10:15:34 +0000] "GET /about HTTP/1.1" 200 2048 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
|
||||
2023-04-12 10:15:35 [NGINX] 172.16.0.11 - - [12/Apr/2023:10:15:35 +0000] "PUT /api/v1/users/123 HTTP/1.1" 200 - "https://example.com/profile" "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"
|
||||
2023-04-12 10:15:36 [NGINX] 172.16.0.13 - - [12/Apr/2023:10:15:36 +0000] "DELETE /api/v1/products/456 HTTP/1.1" 204 - "-" "Dalvik/2.1.0 (Linux; U; Android 10; Pixel 4 Build/QD1A.190821.014.C2)"
|
||||
2023-04-12 10:15:37 [NGINX] 172.16.0.15 - - [12/Apr/2023:10:15:37 +0000] "GET /easter-egg HTTP/1.1" 200 42 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.3"
|
||||
[2015-02-04 10:00:00] production.INFO: Laravel 5.0 released. The world rejoices.
|
||||
|
||||
[2015-12-03 12:00:00] production.INFO: PHP 7.0 introduced scalar type declarations. Minds blown.
|
||||
|
||||
[2016-05-15 14:00:00] production.INFO: Composer installed new dependencies. Dependency hell avoided.
|
||||
|
||||
[2017-08-20 16:00:00] production.INFO: Artisan command executed: migrate. Smooth sailing.
|
||||
|
||||
[2018-01-10 09:00:00] production.INFO: User login successful.
|
||||
|
||||
[2018-01-10 09:05:00] production.ERROR: Database connection failed. Did you try turning it off and on again?
|
||||
|
||||
[2018-01-10 09:10:00] production.WARNING: Deprecated function used in UserController.php. Time to refactor, again.
|
||||
|
||||
[2018-01-10 09:15:00] production.ERROR: Uncaught Exception: Division by zero. Oops, maths is hard.
|
||||
|
||||
[2018-01-10 09:20:00] production.INFO: User logout successful. See you later, alligator.
|
||||
|
||||
[2019-03-25 11:00:00] production.INFO: Cache cleared. Fresh start!
|
||||
|
||||
[2019-06-30 13:00:00] production.ERROR: Syntax error. Missing semicolon strikes again.
|
||||
|
||||
[2020-09-10 15:00:00] production.INFO: User registered. Welcome aboard!
|
||||
|
||||
[2021-11-05 17:00:00] production.WARNING: Low disk space. Time to clean up.
|
||||
|
||||
[2022-12-20 19:00:00] production.INFO: Server rebooted. All systems go.
|
||||
|
||||
[2024-11-14 18:56:20] production.INFO: Unexpected item in the bagging area. Please wait for assistance.
|
||||
render: irc logs
|
||||
|
||||
# Special thanks
|
||||
|
||||
39
.github/ISSUE_TEMPLATE/code_quality_issue.yml
vendored
39
.github/ISSUE_TEMPLATE/code_quality_issue.yml
vendored
@@ -5,7 +5,7 @@ description: Create a code quality issue to help InvoiceShelf keep a clean codeb
|
||||
labels:
|
||||
- code quality
|
||||
- triage
|
||||
projects: ["InvoiceShelf/2"]
|
||||
projects: ['InvoiceShelf/2']
|
||||
assignees:
|
||||
- rihards-simanovics
|
||||
|
||||
@@ -20,6 +20,23 @@ body:
|
||||
possibility your suggestion is already being tracked. Please do a
|
||||
thorough search before creating a new issue.
|
||||
|
||||
# Ask user to confirm they've tried to fix or research the issue before
|
||||
# posting a bug report.
|
||||
- type: checkboxes
|
||||
id: confirm-read-documentation
|
||||
attributes:
|
||||
label: Issue filing prerequisites
|
||||
description: 'Prior to filing an issue please confirm that:'
|
||||
options:
|
||||
- label: I've checked the [documentation](https://docs.invoiceshelf.com/).
|
||||
required: true
|
||||
- label: I've looked for similar issues both Open and Closed.
|
||||
required: true
|
||||
- label: >-
|
||||
I've tried clearing both cache and cookies in my browser or tried
|
||||
opening the app in the Incognito/InPrivate window.
|
||||
required: true
|
||||
|
||||
# Issue body
|
||||
- type: textarea
|
||||
id: description
|
||||
@@ -45,10 +62,12 @@ body:
|
||||
attributes:
|
||||
label: Gains
|
||||
description: What would fixing this code quality issue bring to the source code?
|
||||
value: |
|
||||
- eg. A better readability.
|
||||
- eg. Uncoupling concepts X and Y.
|
||||
- eg. Clarifying the responsibility of class C.
|
||||
placeholder: >-
|
||||
- e.g. Better readability.
|
||||
|
||||
- e.g. Uncoupling concepts X and Y.
|
||||
|
||||
- e.g. Clarifying the responsibility of class C.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -58,10 +77,12 @@ body:
|
||||
attributes:
|
||||
label: Requirements
|
||||
description: Describe all the requirements to solve the code quality issue.
|
||||
value: |
|
||||
- eg. Using a specific design pattern.
|
||||
- eg. Separating Interface I into three new interfaces I1, I2 and I3.
|
||||
- eg. Regrouping the duplicated process into a new helper.
|
||||
placeholder: >-
|
||||
- e.g. Using a specific design pattern.
|
||||
|
||||
- e.g. Separating Interface I into three new interfaces I1, I2 and I3.
|
||||
|
||||
- e.g. Regrouping the duplicated process into a new helper.
|
||||
|
||||
# Remarks
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -6,6 +6,6 @@ contact_links:
|
||||
- name: Translation issue
|
||||
url: https://crowdin.com/project/invoiceshelf
|
||||
about: Help improve translations on Crowdin.
|
||||
- name: Question & Discussion
|
||||
- name: Support, Question & Discussion - Official Discord Server
|
||||
url: https://discord.gg/hwg2FtwWHW
|
||||
about: Post your questions and join the discussion. You can participate in chats on every channel.
|
||||
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
30
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -4,7 +4,8 @@ name: Feature Request
|
||||
labels:
|
||||
- feature request
|
||||
- triage
|
||||
projects: ["InvoiceShelf/2"]
|
||||
type: Feature
|
||||
projects: ['InvoiceShelf/2']
|
||||
assignees:
|
||||
- rihards-simanovics
|
||||
description: >-
|
||||
@@ -21,12 +22,31 @@ body:
|
||||
your suggestion is already being tracked. Please do a thorough search
|
||||
before creating a new issue.
|
||||
|
||||
# Ask user to confirm they've tried to fix or research the issue before
|
||||
# posting a bug report.
|
||||
- type: checkboxes
|
||||
id: confirm-read-documentation
|
||||
attributes:
|
||||
label: Issue filing prerequisites
|
||||
description: 'Prior to filing an issue please confirm that:'
|
||||
options:
|
||||
- label: I've checked the [documentation](https://docs.invoiceshelf.com/).
|
||||
required: true
|
||||
- label: I've looked for similar issues with feature requests both Open and Closed.
|
||||
required: true
|
||||
- label: >-
|
||||
I've tried clearing both cache and cookies in my browser or tried
|
||||
opening the app in the Incognito/InPrivate window.
|
||||
required: true
|
||||
|
||||
# Description
|
||||
- type: textarea
|
||||
id: what-feature
|
||||
attributes:
|
||||
label: What feature or improvement do you think would benefit InvoiceShelf?
|
||||
description: Please include your use case
|
||||
placeholder: >-
|
||||
I propose that feature J be implemented as I believe it will greatly benefit the application.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -37,7 +57,7 @@ body:
|
||||
---
|
||||
|
||||
|
||||
Please include a list changes required to make this improvement. A good
|
||||
Please include a list of changes required to make this improvement. A good
|
||||
rule of thumb is to start your proposal with no more than 7 high-level
|
||||
requirements.
|
||||
|
||||
@@ -47,9 +67,11 @@ body:
|
||||
attributes:
|
||||
label: Requirements
|
||||
description: Describe all the requirements to make your idea happen.
|
||||
value: |
|
||||
placeholder: >-
|
||||
- This proposal will accomplish X
|
||||
|
||||
- This proposal will accomplish Y
|
||||
|
||||
- This proposal will accomplish Z
|
||||
validations:
|
||||
required: true
|
||||
@@ -59,7 +81,7 @@ body:
|
||||
id: invoiceshelf_version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of InvoiceShelf are you using?
|
||||
description: Which version of InvoiceShelf are you currently using?
|
||||
placeholder: v0.0.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
17
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
17
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
## Pre-review request checklist
|
||||
|
||||
- [ ] (\*) I have listed all changes in the `Changes` section.
|
||||
- [ ] (opt) I have listed all issues this PR addresses in the `Issues` section [with the issue/discussion IDs](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue).
|
||||
- [ ] (\*) I have tested my changes.
|
||||
- [ ] (\*) I have considered backwards compatibility.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added feature X
|
||||
- Removed Code Y
|
||||
- Refactored code Z
|
||||
|
||||
## Issues
|
||||
|
||||
- fixes #
|
||||
- closes #
|
||||
18
.github/release.yml
vendored
Normal file
18
.github/release.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
changelog:
|
||||
categories:
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Maintenance
|
||||
labels:
|
||||
- chore
|
||||
- dependencies
|
||||
- ci
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
104
.github/workflows/check.yaml
vendored
104
.github/workflows/check.yaml
vendored
@@ -3,23 +3,41 @@ name: Check
|
||||
# Run this workflow every time a new commit pushed to your repository
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'public/build/*.js'
|
||||
- 'public/build/**/*.js'
|
||||
branches-ignore:
|
||||
tags-ignore:
|
||||
- "*"
|
||||
branches-ignore:
|
||||
- 'translations'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'public/build/*.js'
|
||||
- 'public/build/**/*.js'
|
||||
branches-ignore:
|
||||
branches-ignore:
|
||||
- 'translations'
|
||||
# Allow manually triggering the workflow.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: 🔍 Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
php: ${{ steps.filter.outputs.php }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
php:
|
||||
- 'app/**'
|
||||
- 'config/**'
|
||||
- 'database/**'
|
||||
- 'routes/**'
|
||||
- 'tests/**'
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'phpunit.xml'
|
||||
|
||||
kill_previous:
|
||||
name: 0️⃣ Kill previous runs
|
||||
runs-on: ubuntu-latest
|
||||
@@ -36,11 +54,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- kill_previous
|
||||
- changes
|
||||
if: needs.changes.outputs.php == 'true'
|
||||
steps:
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.2
|
||||
php-version: 8.4
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -55,18 +75,19 @@ jobs:
|
||||
name: 2️⃣ PHP ${{ matrix.php-version }} Tests
|
||||
needs:
|
||||
- php_syntax_errors
|
||||
- changes
|
||||
if: needs.changes.outputs.php == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php-version:
|
||||
- 8.2
|
||||
- 8.3
|
||||
- 8.4
|
||||
env:
|
||||
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP Action
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -79,10 +100,10 @@ jobs:
|
||||
- name: Install Composer dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
@@ -92,52 +113,3 @@ jobs:
|
||||
|
||||
- name: Apply tests ${{ matrix.php-version }}
|
||||
run: php artisan test
|
||||
|
||||
createReleaseFile:
|
||||
name: 3️⃣ Build / Upload - Release File
|
||||
if: github.ref_type == 'tag'
|
||||
needs:
|
||||
- tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.2
|
||||
extensions: ${{ env.extensions }}
|
||||
coverage: none
|
||||
|
||||
- name: Install Composer dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
with:
|
||||
composer-options: --no-dev
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
|
||||
- name: Compile Front-end
|
||||
run: npm run build
|
||||
|
||||
- name: Build Dist
|
||||
run: |
|
||||
make clean dist
|
||||
|
||||
- name: Upload package
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
file: InvoiceShelf.zip
|
||||
asset_name: InvoiceShelf.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
|
||||
259
.github/workflows/docker.yaml
vendored
Normal file
259
.github/workflows/docker.yaml
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
# Run nightly at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Docker tag'
|
||||
required: true
|
||||
default: 'latest'
|
||||
|
||||
jobs:
|
||||
php_syntax_errors:
|
||||
name: 1️⃣ PHP Code Style errors
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
|
||||
- name: Check source code for syntax errors
|
||||
run: ./vendor/bin/pint --test
|
||||
|
||||
tests:
|
||||
name: 2️⃣ PHP Tests
|
||||
if: github.event_name == 'release'
|
||||
needs:
|
||||
- php_syntax_errors
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php-version:
|
||||
- 8.4
|
||||
env:
|
||||
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP Action
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions: ${{ env.extensions }}
|
||||
coverage: xdebug
|
||||
tools: pecl, composer
|
||||
|
||||
- name: Install Composer dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
|
||||
- name: Compile Front-end
|
||||
run: npm run build
|
||||
|
||||
- name: Apply tests ${{ matrix.php-version }}
|
||||
run: php artisan test
|
||||
|
||||
release_artifact_build:
|
||||
name: 🏗️ Build / Upload - Release File
|
||||
if: github.event_name == 'release'
|
||||
needs:
|
||||
- tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
extensions: ${{ env.extensions }}
|
||||
coverage: none
|
||||
|
||||
- name: Install Composer dependencies
|
||||
uses: ramsey/composer-install@v2
|
||||
with:
|
||||
composer-options: --no-dev
|
||||
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
|
||||
- name: Compile Front-end
|
||||
run: npm run build
|
||||
|
||||
- name: Build Dist
|
||||
run: |
|
||||
make clean dist
|
||||
|
||||
- name: Upload package
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
file: InvoiceShelf.zip
|
||||
asset_name: InvoiceShelf.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
|
||||
release_docker_build:
|
||||
name: 🐳 Release Docker Build
|
||||
if: github.event_name == 'release'
|
||||
needs:
|
||||
- tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: invoiceshelf/invoiceshelf
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
manual_docker_build:
|
||||
name: 🛠️ Manual Docker Build
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: invoiceshelf/invoiceshelf:${{ github.event.inputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
nightly_build:
|
||||
name: 🌙 Nightly Docker Build
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
branch: [master, develop]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check for recent changes
|
||||
id: changes
|
||||
run: |
|
||||
# Check if there are commits in the last 24 hours
|
||||
RECENT_COMMITS=$(git log --since="24 hours ago" --oneline | wc -l)
|
||||
echo "recent_commits=$RECENT_COMMITS" >> $GITHUB_OUTPUT
|
||||
if [ "$RECENT_COMMITS" -gt 0 ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Set Docker tag
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ matrix.branch }}" = "master" ]; then
|
||||
echo "tag=nightly" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ matrix.branch }}" = "develop" ]; then
|
||||
echo "tag=alpha" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: invoiceshelf/invoiceshelf:${{ steps.tag.outputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.changes.outputs.has_changes == 'false'
|
||||
run: |
|
||||
echo "No commits found in the last 24 hours for ${{ matrix.branch }} branch. Skipping build."
|
||||
96
.github/workflows/release.yaml
vendored
Normal file
96
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
extensions: ${{ env.extensions }}
|
||||
tools: composer
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Prepare release directory
|
||||
run: |
|
||||
mkdir -p /tmp/InvoiceShelf/public
|
||||
|
||||
cp -r app /tmp/InvoiceShelf/
|
||||
cp -r bootstrap /tmp/InvoiceShelf/
|
||||
cp -r config /tmp/InvoiceShelf/
|
||||
cp -r database /tmp/InvoiceShelf/
|
||||
cp -r public/build /tmp/InvoiceShelf/public/
|
||||
cp -r public/favicons /tmp/InvoiceShelf/public/
|
||||
cp public/.htaccess /tmp/InvoiceShelf/public/
|
||||
cp public/index.php /tmp/InvoiceShelf/public/
|
||||
cp public/robots.txt /tmp/InvoiceShelf/public/
|
||||
cp public/web.config /tmp/InvoiceShelf/public/
|
||||
cp -r resources /tmp/InvoiceShelf/
|
||||
cp -r lang /tmp/InvoiceShelf/
|
||||
cp -r routes /tmp/InvoiceShelf/
|
||||
cp -r storage /tmp/InvoiceShelf/
|
||||
cp -r vendor /tmp/InvoiceShelf/ 2>/dev/null || true
|
||||
cp -r scripts /tmp/InvoiceShelf/
|
||||
cp version.md /tmp/InvoiceShelf/
|
||||
cp .env.example /tmp/InvoiceShelf/
|
||||
cp artisan /tmp/InvoiceShelf/
|
||||
cp composer.json /tmp/InvoiceShelf/
|
||||
cp composer.lock /tmp/InvoiceShelf/
|
||||
cp LICENSE /tmp/InvoiceShelf/
|
||||
cp readme.md /tmp/InvoiceShelf/
|
||||
cp SECURITY.md /tmp/InvoiceShelf/
|
||||
cp server.php /tmp/InvoiceShelf/
|
||||
|
||||
# Clean up runtime artifacts
|
||||
find /tmp/InvoiceShelf -wholename '*/[Tt]ests/*' -delete
|
||||
find /tmp/InvoiceShelf -wholename '*/[Tt]est/*' -delete
|
||||
rm -rf /tmp/InvoiceShelf/storage/framework/cache/data/* 2>/dev/null || true
|
||||
rm -f /tmp/InvoiceShelf/storage/framework/sessions/* 2>/dev/null || true
|
||||
rm -f /tmp/InvoiceShelf/storage/framework/views/* 2>/dev/null || true
|
||||
rm -f /tmp/InvoiceShelf/storage/logs/* 2>/dev/null || true
|
||||
touch /tmp/InvoiceShelf/storage/logs/laravel.log
|
||||
|
||||
- name: Generate manifest
|
||||
run: php scripts/generate-manifest.php /tmp/InvoiceShelf
|
||||
|
||||
- name: Create zip
|
||||
working-directory: /tmp
|
||||
run: zip -r InvoiceShelf.zip InvoiceShelf/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: /tmp/InvoiceShelf.zip
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -14,13 +14,16 @@ Homestead.yaml
|
||||
.rnd
|
||||
/.expo
|
||||
/.vscode
|
||||
/.devcontainer
|
||||
.gitkeep
|
||||
/public/docs
|
||||
/.scribe
|
||||
!storage/fonts/.gitkeep
|
||||
.DS_Store
|
||||
.php-cs-fixer.cache
|
||||
.devenvconfig
|
||||
/storage/fonts*
|
||||
package-lock.json
|
||||
/.dev/docker-compose.yml
|
||||
/.dev/docker-compose.yaml
|
||||
/docker/development/docker-compose.yml
|
||||
/docker/production/docker-compose.yml
|
||||
/docker-compose.yaml
|
||||
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
5909
.phpstorm.meta.php
Normal file
5909
.phpstorm.meta.php
Normal file
File diff suppressed because it is too large
Load Diff
255
AGENTS.md
Normal file
255
AGENTS.md
Normal file
@@ -0,0 +1,255 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- vue (VUE) - v3
|
||||
- eslint (ESLINT) - v9
|
||||
- prettier (PRETTIER) - v3
|
||||
- tailwindcss (TAILWINDCSS) - v3
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||
- `medialibrary-development` — Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== invoiceshelf rules ===
|
||||
|
||||
# InvoiceShelf Architecture
|
||||
|
||||
## Service Pattern (Required)
|
||||
All business logic must live in Service classes (`app/Services/`). This is mandatory — do not put business logic in Models or Controllers.
|
||||
- **Controllers** are thin: authorize, call the service, return a response.
|
||||
- **Models** only contain relationships, scopes, accessors, mutators, and constants.
|
||||
- **Services** are injected into controllers via constructor injection.
|
||||
- Check existing services in `app/Services/` for patterns before creating new ones.
|
||||
|
||||
## Testing (TDD)
|
||||
InvoiceShelf follows TDD development style. Every new feature or bug fix must have tests.
|
||||
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions). These are the primary test type.
|
||||
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation.
|
||||
- Write tests before or alongside implementation, not after.
|
||||
|
||||
## Roles
|
||||
- **`super admin`** — global platform admin role (unscoped, manages all companies)
|
||||
- **`owner`** — company-level admin role (scoped to a specific company via Bouncer, full access to that company)
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan Commands
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`).
|
||||
- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Debugging
|
||||
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly.
|
||||
- To read configuration values, read the config files directly or run `php artisan config:show [key]`.
|
||||
- To inspect routes, run `php artisan route:list` directly.
|
||||
- To check environment variables, read the `.env` file directly.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
|
||||
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
|
||||
=== spatie/laravel-medialibrary rules ===
|
||||
|
||||
## Media Library
|
||||
|
||||
- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images.
|
||||
- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
InvoiceShelf is an open-source invoicing and expense tracking application built with Laravel 13 (PHP 8.4) and Vue 3. It supports multi-company tenancy, customer portals, recurring invoices, and PDF generation.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
composer run dev # Starts PHP server, queue listener, log tail, and Vite dev server concurrently
|
||||
npm run dev # Vite dev server only
|
||||
npm run build # Production frontend build
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
php artisan test --compact # Run all tests
|
||||
php artisan test --compact --filter=testName # Run specific test
|
||||
./vendor/bin/pest --stop-on-failure # Run via Pest directly
|
||||
make test # Makefile shortcut
|
||||
```
|
||||
|
||||
Tests use SQLite in-memory DB, configured in `phpunit.xml`. Tests seed via `DatabaseSeeder` + `DemoSeeder` in `beforeEach`. Authenticate with `Sanctum::actingAs()` and set the `company` header.
|
||||
|
||||
### Code Style
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent # Fix style on modified PHP files
|
||||
vendor/bin/pint --test # Check style without fixing (CI uses this)
|
||||
```
|
||||
|
||||
### Artisan Generators
|
||||
Always use `php artisan make:*` with `--no-interaction` to create new files (models, controllers, migrations, tests, etc.).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Multi-Tenancy
|
||||
Every major model has a `company_id` foreign key. The `CompanyMiddleware` sets the active company from the `company` request header. Bouncer authorization is scoped to the company level via `DefaultScope` (`app/Bouncer/Scopes/DefaultScope.php`).
|
||||
|
||||
### Authentication
|
||||
Three guards: `web` (session), `api` (Sanctum tokens for `/api/v1/`), `customer` (session for customer portal). API routes use `auth:sanctum` middleware; customer portal uses `auth:customer`.
|
||||
|
||||
### Routing
|
||||
- **API**: All endpoints under `/api/v1/` in `routes/api.php`, grouped with `auth:sanctum`, `company`, and `bouncer` middleware
|
||||
- **Web**: `routes/web.php` serves PDF endpoints, auth pages, and catch-all SPA routes (`/admin/{vue?}`, `/{company:slug}/customer/{vue?}`)
|
||||
|
||||
### Frontend
|
||||
- Vue 3 + TypeScript + Pinia + vue-router + Tailwind v4 (`@tailwindcss/vite`)
|
||||
- Entry point: `resources/scripts/main.ts` (single Vite input)
|
||||
- Feature-folder layout under `resources/scripts/features/{admin,auth,company,customer-portal,...}` — each feature owns its own `routes.ts`, `views/`, `components/`
|
||||
- Shared layers: `resources/scripts/{api,stores,components,composables,layouts,plugins,utils,types,config}`
|
||||
- Path aliases: `@` → `resources/` (so most imports look like `@/scripts/api/client`, `@/scripts/stores/global.store`); `$fonts` → `resources/static/fonts`; `$images` → `resources/static/img`. There is no `@v2` alias — that was retired when the legacy v1 SPA was deleted.
|
||||
- i18n: `lang/*.json` are dynamically imported by `resources/scripts/plugins/i18n.ts`. Locale-code → filename mismatches (e.g. `pt_BR` → `pt-br.json`) live in `LOCALE_FILE_MAP`. English is statically bundled; other locales lazy-load. Only edit `lang/en.json` directly — other locales are Crowdin-sourced.
|
||||
- Vite dev server expects the `invoiceshelf.test` hostname (configured in `vite.config.js`)
|
||||
|
||||
### CSS Theme Tokens
|
||||
The styling system uses **Tailwind v4 with CSS custom properties as the source of truth** — colors are not configured in JS, they live in CSS and are exposed to Tailwind via the `@theme` directive. Two files own this:
|
||||
|
||||
1. **`resources/css/themes.css`** — defines every color as a CSS custom property on `:root` (light) and `[data-theme="dark"]` (dark). This is where you change actual values.
|
||||
2. **`resources/css/invoiceshelf.css`** — has an `@theme inline { ... }` block that **registers** each custom property as a Tailwind theme token (e.g. `--color-heading: var(--color-heading);`), making it available as utility classes (`bg-heading`, `text-heading`, `border-heading`, etc.). The block also uses the legacy `@theme { --spacing-88: 22rem; --font-base: Poppins, sans-serif; }` for non-color tokens.
|
||||
|
||||
**Token categories defined today:**
|
||||
- `primary-{50…950}` — brand color scale
|
||||
- `surface`, `surface-secondary`, `surface-tertiary`, `surface-muted` — background depth tiers
|
||||
- `heading`, `body`, `muted`, `subtle` — text emphasis tiers
|
||||
- `line-{light,default,strong}` — borders
|
||||
- `hover`, `hover-strong` — hover backgrounds
|
||||
- `header-from`, `header-to` — fixed header gradient stops (not dark-mode-aware)
|
||||
- `btn-primary`, `btn-primary-hover` — button colors (fixed, always bold)
|
||||
- `status-{yellow,green,blue,red,purple}` — status badge text colors
|
||||
- `alert-{warning,error,success}-{bg,text}` — alert variants
|
||||
|
||||
**Dark mode** is toggled via the `[data-theme="dark"]` attribute on the `<html>` element. The same custom-property names get redefined under that selector — components do **not** need `dark:` variants or conditional logic, they just reference the semantic tokens and the right value is picked up automatically.
|
||||
|
||||
**Adding a new color token is a two-step ritual:**
|
||||
1. Add the custom property to **both** `:root` and `[data-theme="dark"]` in `themes.css`
|
||||
2. Add a matching `--color-X: var(--color-X);` line inside the `@theme inline` block in `invoiceshelf.css`
|
||||
|
||||
After that the token is usable as `bg-X` / `text-X` / `border-X` in Vue templates and as `var(--color-X)` in raw CSS. Skip step 2 and the value exists at the CSS level but Tailwind utility classes won't be generated.
|
||||
|
||||
**Convention — never hardcode hex/rgb values in components.** Use the semantic tokens: `text-heading` not `text-gray-900`, `bg-surface` not `bg-white`, `border-line-default` not `border-gray-300`. Hardcoded values won't follow dark-mode flips and will diverge from the rest of the app over time. There are **no exceptions** in the project — even the auth pages (which sit outside the admin chrome) use the same `bg-surface` / `text-heading` / `border-line-default` vocabulary as `BaseCard`, just composed differently.
|
||||
|
||||
### Backend Patterns
|
||||
- **Authorization**: Silber/Bouncer with policies in `app/Policies/`. Controllers use `$this->authorize()`.
|
||||
- **Validation**: Form Request classes, never inline validation
|
||||
- **API responses**: Eloquent API Resources in `app/Http/Resources/`
|
||||
- **PDF generation**: Pluggable driver — `dompdf` (default, via `GeneratesPdfTrait`) or `gotenberg` (headless Chromium). Driver chosen per company through the **PDF Generation** admin settings page.
|
||||
- **Email**: Mailable classes with `EmailLog` tracking. Mail driver is configurable globally and may be overridden per-company.
|
||||
- **File storage**: Spatie MediaLibrary backed by the **FileDisk** model — admins create named disk entries (local / S3 / Dropbox / DigitalOcean Spaces) and assign them to purposes (`media_storage`, `pdf_storage`, `backup_storage`) in **Admin → File Disks → Disk Assignments**. New uploads go to the assigned disk; existing files stay where they were and require `php artisan media:secure` to migrate.
|
||||
- **Serial numbers**: `SerialNumberService`
|
||||
- **Company settings**: `CompanySetting` model (key-value per company)
|
||||
- **User settings**: User-level preferences (notably `language`) stored as JSON via `setSettings()`. The sentinel value `'default'` means "inherit the company-level setting" — used for the per-user language preference so promoting/inviting members doesn't freeze a copy of the inviter's language.
|
||||
|
||||
### PDF Font System
|
||||
PDFs ship with bundled **Noto Sans** (Latin / Greek / Cyrillic) as the default face. Non-Latin scripts come from on-demand **Font Packages** managed in **Admin → Font Packages** and defined in `FontService::FONT_PACKAGES` (`app/Services/FontService.php`). Currently shipped packages: `noto-sans` (bundled, marker only), `noto-sans-{sc,tc,jp,kr}` (CJK), `noto-sans-hebrew`, `noto-naskh-arabic` (covers `ar`/`fa`/`ur`), `noto-sans-devanagari` (`hi`), `sarabun` (Thai). `GeneratesPdfTrait::ensureFontsForLocale()` synchronously installs the matching package on the first PDF render for a given company language.
|
||||
|
||||
Two non-obvious constraints when extending the font system:
|
||||
1. **dompdf's PHP-Font-Lib does not parse variable fonts** (`fvar`/`gvar` tables). Any new package must source **static TTF** files — Google Fonts' main repo ships variable fonts and produces empty boxes. Reliable static-TTF sources used today: `openmaptiles/fonts` for non-CJK Noto scripts, `life888888/cjk-fonts-ttf` for the CJK packages, `google/fonts/ofl/sarabun` for Thai.
|
||||
2. **dompdf does not glyph-fall-back through the `font-family` chain** — it uses the *first* font for ALL characters. So locale-specific packages must be the **primary** font for that locale, not a fallback. Selection happens in `FontService::getFontFamilyForLocale()`. This is also why a Latin-locale company with a Hebrew customer name will still render boxes for the Hebrew text — solving that needs Gotenberg or a custom mid-render font-switching pass.
|
||||
|
||||
The bundled NotoSans is also surfaced as a `bundled: true` package entry (no download URL, files served from `resources/static/fonts/` instead of `storage/fonts/`) so it appears alongside the on-demand packages in the admin UI with a "Bundled" pill instead of an Install button.
|
||||
|
||||
### Database
|
||||
Supports MySQL, PostgreSQL, and SQLite. Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1 queries.
|
||||
|
||||
### Service Pattern
|
||||
All business logic must live in Service classes (`app/Services/`), not in Models or Controllers. Controllers are thin — they authorize, call the service, and return a response. Models only contain relationships, scopes, accessors, mutators, and constants. Services are injected via constructor injection.
|
||||
|
||||
### Testing (TDD)
|
||||
InvoiceShelf follows TDD development style:
|
||||
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions)
|
||||
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation
|
||||
- Write tests before or alongside implementation. Every new feature or bug fix must have tests.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments
|
||||
- TS / Vue: camelCase, `<script setup lang="ts">`, prefer Composition API + Pinia stores over component-local state for anything cross-cutting
|
||||
- Always check sibling files for patterns before creating new ones
|
||||
- Use `config()` helper, never `env()` outside config files
|
||||
- Every change must have tests
|
||||
- Run `vendor/bin/pint --dirty --format agent` after modifying PHP files
|
||||
- After editing `lang/en.json` or any file under `resources/scripts/`, rebuild via `npm run build` — the bundled chunks (including locale chunks) are content-hashed by Vite, so the browser will pick them up on hard refresh
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
GitHub Actions (`check.yaml`): runs Pint style check, then builds frontend and runs Pest tests on PHP 8.4.
|
||||
68
CONTRIBUTING.md
Normal file
68
CONTRIBUTING.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Contributing to InvoiceShelf
|
||||
|
||||
Thank you for your interest in contributing to InvoiceShelf! We welcome contributions from the community and appreciate your effort to improve this project.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork the repository** and create a new branch from `master`
|
||||
2. **Make your changes** — follow the existing code style and conventions
|
||||
3. **Write tests** if applicable
|
||||
4. **Run the test suite** to ensure nothing is broken
|
||||
5. **Submit a Pull Request** with a clear description of the changes
|
||||
|
||||
## Development Setup
|
||||
|
||||
Please refer to the [README](README.md) for instructions on setting up the development environment.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **PHP**: Follow PSR-12 standards. Run `vendor/bin/pint --dirty` before committing
|
||||
- **TypeScript/Vue**: Follow the existing patterns in `resources/scripts/`
|
||||
- **Tests**: Every new feature or bug fix should include tests
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
By submitting a pull request, patch, commit, or any other contribution (collectively, "Contribution") to this repository, you agree to the following terms:
|
||||
|
||||
### Grant of Rights
|
||||
|
||||
You hereby grant to **Ideologix Media Dooel** (the maintainer and owner of InvoiceShelf), and to recipients of software distributed by Ideologix Media Dooel, a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to:
|
||||
|
||||
- Reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contribution and any derivative works thereof
|
||||
- Use your Contribution for any purpose, including but not limited to incorporating it into InvoiceShelf or any other product or service
|
||||
- Relicense your Contribution under any license, including proprietary licenses
|
||||
|
||||
### Copyright Assignment
|
||||
|
||||
You agree that your Contribution, once merged, becomes part of the InvoiceShelf project and that **Ideologix Media Dooel** retains full rights to the combined work, including the right to:
|
||||
|
||||
- Offer the software under dual licensing (open-source and commercial)
|
||||
- Enforce the license terms against third parties
|
||||
- Make licensing decisions without requiring additional permission from contributors
|
||||
|
||||
### Representations
|
||||
|
||||
By submitting a Contribution, you represent that:
|
||||
|
||||
1. You have the legal right to grant the above license
|
||||
2. Your Contribution is your original work, or you have sufficient rights to submit it
|
||||
3. Your Contribution does not violate any third-party intellectual property rights
|
||||
4. You understand that your Contribution is provided voluntarily and is not confidential
|
||||
|
||||
### Scope
|
||||
|
||||
This agreement applies to all Contributions made to any repository owned by InvoiceShelf / Ideologix Media Dooel, including but not limited to code, documentation, translations, and design assets.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Use [GitHub Issues](https://github.com/InvoiceShelf/InvoiceShelf/issues) for bug reports and feature requests
|
||||
- Include steps to reproduce for bug reports
|
||||
- Check existing issues before creating a new one
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to open a discussion on GitHub.
|
||||
|
||||
---
|
||||
|
||||
*InvoiceShelf is maintained by [Ideologix Media Dooel](https://ideologix.com/) and licensed under the [AGPL-3.0 License](LICENSE).*
|
||||
2
Makefile
2
Makefile
@@ -29,6 +29,7 @@ dist-gen: clean composer npm-build
|
||||
@cp -r routes InvoiceShelf
|
||||
@cp -r storage InvoiceShelf
|
||||
@cp -r vendor InvoiceShelf 2> /dev/null || true
|
||||
@cp -r scripts InvoiceShelf
|
||||
@cp -r version.md InvoiceShelf
|
||||
@cp -r .env.example InvoiceShelf
|
||||
@cp -r artisan InvoiceShelf
|
||||
@@ -47,6 +48,7 @@ dist-clean: dist-gen
|
||||
@rm InvoiceShelf/storage/framework/sessions/* 2> /dev/null || true
|
||||
@rm InvoiceShelf/storage/framework/views/* 2> /dev/null || true
|
||||
@rm InvoiceShelf/storage/logs/* 2> /dev/null || true
|
||||
@php scripts/generate-manifest.php InvoiceShelf
|
||||
|
||||
dist: dist-clean
|
||||
@zip -r InvoiceShelf.zip InvoiceShelf
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please email security@invoiceshelf.com to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again.
|
||||
Please email **security@invoiceshelf.com** and cc **security@griffin-web.studio** to report any security vulnerabilities. In the unlikely event that you haven’t heard back, try reaching out on Discord to one of our moderators.
|
||||
|
||||
We will acknowledge receipt of your report and strive to provide regular updates on our progress. If you're curious about the status of your disclosure, please feel free to email us again.
|
||||
|
||||
12488
_ide_helper.php
12488
_ide_helper.php
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Space\PdfTemplateUtils;
|
||||
use App\Services\Pdf\PdfTemplateUtils;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Space\ModuleInstaller;
|
||||
use App\Services\Module\ModuleInstaller;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InstallModuleCommand extends Command
|
||||
|
||||
132
app/Console/Commands/MigrateMediaToPrivateDisk.php
Normal file
132
app/Console/Commands/MigrateMediaToPrivateDisk.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\FileDisk;
|
||||
use App\Models\Setting;
|
||||
use App\Services\FileDiskService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class MigrateMediaToPrivateDisk extends Command
|
||||
{
|
||||
protected $signature = 'media:secure {--dry-run : Show what would be moved without moving}';
|
||||
|
||||
protected $description = 'Move sensitive media files (receipts) from the public disk to the private disk';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$targetDisk = $this->resolveTargetDisk();
|
||||
|
||||
if (! $targetDisk) {
|
||||
$this->error('No target disk found. Set a default FileDisk or configure media_disk_id setting.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targetDiskName = app(FileDiskService::class)->registerDisk($targetDisk);
|
||||
$targetRoot = config('filesystems.disks.'.$targetDiskName.'.root');
|
||||
|
||||
if (! $targetRoot) {
|
||||
$this->error('Could not resolve target disk root path.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$records = DB::table('media')
|
||||
->where('disk', 'public')
|
||||
->where(function ($query) {
|
||||
$query->where('collection_name', 'receipts');
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
$this->info('No media files to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Found '.$records->count().' file(s) to migrate.');
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
foreach ($records as $record) {
|
||||
$this->line(" Would move: media/{$record->id}/{$record->file_name}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$moved = 0;
|
||||
$skipped = 0;
|
||||
$publicMediaRoot = public_path('media');
|
||||
|
||||
$bar = $this->output->createProgressBar($records->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($records as $record) {
|
||||
$relativePath = $record->id.DIRECTORY_SEPARATOR.$record->file_name;
|
||||
$sourcePath = $publicMediaRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||
$destPath = $targetRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||
|
||||
if (! file_exists($sourcePath)) {
|
||||
$skipped++;
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$destDir = dirname($destPath);
|
||||
if (! File::isDirectory($destDir)) {
|
||||
File::makeDirectory($destDir, 0755, true);
|
||||
}
|
||||
|
||||
File::move($sourcePath, $destPath);
|
||||
|
||||
DB::table('media')
|
||||
->where('id', $record->id)
|
||||
->update(['disk' => $targetDiskName]);
|
||||
|
||||
$moved++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->info("Done. Moved: {$moved}, Skipped (missing): {$skipped}");
|
||||
|
||||
// Clean up empty directories in public/media
|
||||
$this->cleanEmptyDirectories($publicMediaRoot);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveTargetDisk(): ?FileDisk
|
||||
{
|
||||
$mediaDiskId = Setting::getSetting('media_disk_id');
|
||||
|
||||
if ($mediaDiskId) {
|
||||
return FileDisk::find($mediaDiskId);
|
||||
}
|
||||
|
||||
return FileDisk::where('set_as_default', true)->first();
|
||||
}
|
||||
|
||||
private function cleanEmptyDirectories(string $path): void
|
||||
{
|
||||
if (! File::isDirectory($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directories = File::directories($path);
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
$this->cleanEmptyDirectories($dir);
|
||||
|
||||
if (count(File::allFiles($dir)) === 0 && count(File::directories($dir)) === 0) {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class ResetApp extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clean database, database_created and public/storage folder';
|
||||
protected $description = 'Clean database and public/storage folder';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Space\Updater;
|
||||
use App\Services\Update\Updater;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
// Implementation taken from Akaunting - https://github.com/akaunting/akaunting
|
||||
class UpdateCommand extends Command
|
||||
@@ -98,7 +98,7 @@ class UpdateCommand extends Command
|
||||
|
||||
public function getInstalledVersion()
|
||||
{
|
||||
return Setting::getSetting('version');
|
||||
return preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
|
||||
}
|
||||
|
||||
public function getLatestVersionResponse()
|
||||
|
||||
22
app/Facades/Hashids.php
Normal file
22
app/Facades/Hashids.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @method static string encode(mixed ...$numbers)
|
||||
* @method static array decode(string $hash)
|
||||
* @method static string encodeHex(string $str)
|
||||
* @method static string decodeHex(string $hash)
|
||||
* @method static \Hashids\Hashids connection(string|null $name = null)
|
||||
*/
|
||||
class Hashids extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'hashids';
|
||||
}
|
||||
}
|
||||
16
app/Facades/Pdf.php
Normal file
16
app/Facades/Pdf.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @method static \Psr\Http\Message\ResponseInterface loadView(string $template)
|
||||
*/
|
||||
class Pdf extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor()
|
||||
{
|
||||
return 'pdf.driver';
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
48
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
|
||||
|
||||
$dbDriver = config('database.default');
|
||||
$dbVersion = $this->getDatabaseVersion($dbDriver);
|
||||
|
||||
return response()->json([
|
||||
'app_version' => $version,
|
||||
'php_version' => phpversion(),
|
||||
'database' => [
|
||||
'driver' => $dbDriver,
|
||||
'version' => $dbVersion,
|
||||
],
|
||||
'counts' => [
|
||||
'companies' => Company::count(),
|
||||
'users' => User::count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function getDatabaseVersion(string $driver): ?string
|
||||
{
|
||||
try {
|
||||
return match ($driver) {
|
||||
'mysql' => DB::selectOne('SELECT VERSION() as version')?->version,
|
||||
'pgsql' => DB::selectOne('SHOW server_version')?->server_version,
|
||||
'sqlite' => DB::selectOne('SELECT sqlite_version() as version')?->version,
|
||||
default => null,
|
||||
};
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/Http/Controllers/Admin/BackupsController.php
Normal file
117
app/Http/Controllers/Admin/BackupsController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\CreateBackupJob;
|
||||
use App\Rules\Backup\PathToZip;
|
||||
use App\Services\Backup\BackupService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Spatie\Backup\BackupDestination\Backup;
|
||||
use Spatie\Backup\Helpers\Format;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class BackupsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BackupService $backupService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage backups');
|
||||
|
||||
try {
|
||||
$destination = $this->backupService->getDestination($request->file_disk_id);
|
||||
|
||||
$backups = $destination
|
||||
->backups()
|
||||
->map(function (Backup $backup) {
|
||||
return [
|
||||
'path' => $backup->path(),
|
||||
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
|
||||
'size' => Format::humanReadableSize($backup->sizeInBytes()),
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'backups' => $backups,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'backups' => [],
|
||||
'error' => 'invalid_disk_credentials',
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage backups');
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
dispatch(new CreateBackupJob($data))->onQueue(config('backup.queue.name'));
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy($disk, Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage backups');
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', new PathToZip],
|
||||
]);
|
||||
|
||||
$destination = $this->backupService->getDestination($request->file_disk_id);
|
||||
|
||||
$destination
|
||||
->backups()
|
||||
->first(function (Backup $backup) use ($validated) {
|
||||
return $backup->path() === $validated['path'];
|
||||
})
|
||||
->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function download(Request $request): Response|StreamedResponse
|
||||
{
|
||||
$this->authorize('manage backups');
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', new PathToZip],
|
||||
]);
|
||||
|
||||
$destination = $this->backupService->getDestination($request->file_disk_id);
|
||||
|
||||
$backup = $destination->backups()->first(function (Backup $backup) use ($validated) {
|
||||
return $backup->path() === $validated['path'];
|
||||
});
|
||||
|
||||
if (! $backup) {
|
||||
return response('Backup not found', 422);
|
||||
}
|
||||
|
||||
$fileName = pathinfo($backup->path(), PATHINFO_BASENAME);
|
||||
|
||||
return response()->stream(function () use ($backup) {
|
||||
$stream = $backup->stream();
|
||||
fpassthru($stream);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}, 200, [
|
||||
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Length' => $backup->sizeInBytes(),
|
||||
'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
|
||||
'Pragma' => 'public',
|
||||
]);
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Admin/CompaniesController.php
Normal file
113
app/Http/Controllers/Admin/CompaniesController.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Facades\Hashids;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AdminCompanyUpdateRequest;
|
||||
use App\Http\Requests\CompaniesRequest;
|
||||
use App\Http\Resources\CompanyResource;
|
||||
use App\Models\Company;
|
||||
use App\Services\CompanyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
class CompaniesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CompanyService $companyService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$companies = Company::query()
|
||||
->with(['owner', 'address'])
|
||||
->when($request->has('search'), function ($query) use ($request) {
|
||||
$query->where('name', 'like', '%'.$request->search.'%');
|
||||
})
|
||||
->when($request->has('orderByField') && $request->has('orderBy'), function ($query) use ($request) {
|
||||
$query->orderBy($request->orderByField, $request->orderBy);
|
||||
}, function ($query) {
|
||||
$query->orderBy('name', 'asc');
|
||||
})
|
||||
->paginate($request->input('limit', 10));
|
||||
|
||||
return CompanyResource::collection($companies);
|
||||
}
|
||||
|
||||
public function show(Company $company)
|
||||
{
|
||||
$company->load(['owner', 'address']);
|
||||
|
||||
return new CompanyResource($company);
|
||||
}
|
||||
|
||||
public function update(AdminCompanyUpdateRequest $request, Company $company)
|
||||
{
|
||||
$company->update([
|
||||
'name' => $request->name,
|
||||
'vat_id' => $request->vat_id,
|
||||
'tax_id' => $request->tax_id,
|
||||
'owner_id' => $request->owner_id,
|
||||
]);
|
||||
|
||||
if ($request->has('address')) {
|
||||
$company->address()->updateOrCreate(
|
||||
['company_id' => $company->id],
|
||||
$request->address,
|
||||
);
|
||||
}
|
||||
|
||||
$company->load(['owner', 'address']);
|
||||
|
||||
return new CompanyResource($company);
|
||||
}
|
||||
|
||||
public function store(CompaniesRequest $request)
|
||||
{
|
||||
$this->authorize('create company');
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$company = Company::create($request->getCompanyPayload());
|
||||
$company->unique_hash = Hashids::connection(Company::class)->encode($company->id);
|
||||
$company->save();
|
||||
$this->companyService->setupDefaults($company);
|
||||
$user->companies()->attach($company->id);
|
||||
|
||||
BouncerFacade::scope()->to($company->id);
|
||||
$user->assign('owner');
|
||||
|
||||
if ($request->address) {
|
||||
$company->address()->create($request->address);
|
||||
}
|
||||
|
||||
return new CompanyResource($company);
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$company = Company::find($request->header('company'));
|
||||
|
||||
$this->authorize('delete company', $company);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->name !== $company->name) {
|
||||
return respondJson('company_name_must_match_with_given_name', 'Company name must match with given name');
|
||||
}
|
||||
|
||||
$this->companyService->delete($company, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function userCompanies(Request $request)
|
||||
{
|
||||
$companies = $request->user()->companies;
|
||||
|
||||
return CompanyResource::collection($companies);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\General;
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CountryResource;
|
||||
use App\Models\Country;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CountriesController extends Controller
|
||||
@@ -12,7 +13,7 @@ class CountriesController extends Controller
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
23
app/Http/Controllers/Admin/CurrenciesController.php
Normal file
23
app/Http/Controllers/Admin/CurrenciesController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CurrencyResource;
|
||||
use App\Services\CurrencyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CurrenciesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$currencies = $this->currencyService->getAllWithCommonFirst();
|
||||
|
||||
return CurrencyResource::collection($currencies);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Admin/FontController.php
Normal file
52
app/Http/Controllers/Admin/FontController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\FontService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class FontController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FontService $fontService,
|
||||
) {}
|
||||
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage settings');
|
||||
|
||||
return response()->json([
|
||||
'packages' => $this->fontService->getPackageStatuses(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function install(string $package): JsonResponse
|
||||
{
|
||||
$this->authorize('manage settings');
|
||||
|
||||
if (! isset(FontService::FONT_PACKAGES[$package])) {
|
||||
return response()->json(['error' => 'Unknown font package'], 404);
|
||||
}
|
||||
|
||||
$pkg = FontService::FONT_PACKAGES[$package];
|
||||
|
||||
if ($this->fontService->isInstalled($pkg)) {
|
||||
return response()->json(['success' => true, 'message' => 'Already installed']);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fontService->downloadPackage($pkg);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'installed' => true,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UnzipUpdateRequest;
|
||||
use App\Http\Requests\UploadModuleRequest;
|
||||
use App\Services\Module\ModuleInstaller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ModuleInstallationController extends Controller
|
||||
{
|
||||
public function download(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::download(
|
||||
(string) $request->slug,
|
||||
(string) $request->version,
|
||||
$request->checksum_sha256 ? (string) $request->checksum_sha256 : null,
|
||||
);
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function upload(UploadModuleRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::upload($request);
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function unzip(UnzipUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$path = ModuleInstaller::unzip($request->module_name ?? $request->module, $request->path);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
|
||||
public function copy(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::copyFiles($request->module_name ?? $request->module, $request->path);
|
||||
|
||||
return response()->json([
|
||||
'success' => $response,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::complete($request->module_name ?? $request->module, $request->version);
|
||||
|
||||
return response()->json([
|
||||
'success' => $response,
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Admin/Modules/ModulesController.php
Normal file
86
app/Http/Controllers/Admin/Modules/ModulesController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Modules;
|
||||
|
||||
use App\Events\ModuleDisabledEvent;
|
||||
use App\Events\ModuleEnabledEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ModuleResource;
|
||||
use App\Models\Module as ModelsModule;
|
||||
use App\Services\Module\ModuleInstaller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
|
||||
class ModulesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::getModules();
|
||||
|
||||
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->modules)) {
|
||||
return response()->json(['error' => 'marketplace_unavailable'], 503);
|
||||
}
|
||||
|
||||
return ModuleResource::collection(collect($response['body']->modules));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $module)
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$response = ModuleInstaller::getModule($module);
|
||||
|
||||
if (($response['status'] ?? 0) === 404) {
|
||||
return response()->json(['error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->data)) {
|
||||
return response()->json(['error' => 'marketplace_unavailable'], 503);
|
||||
}
|
||||
|
||||
return (new ModuleResource($response['body']->data))
|
||||
->additional(['meta' => [
|
||||
'modules' => ModuleResource::collection(
|
||||
collect($response['body']->meta->modules ?? [])
|
||||
),
|
||||
]]);
|
||||
}
|
||||
|
||||
public function checkToken(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
return ModuleInstaller::checkToken($request->api_token);
|
||||
}
|
||||
|
||||
public function enable(Request $request, string $module): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$module = ModelsModule::where('name', $module)->first();
|
||||
$module->update(['enabled' => true]);
|
||||
$installedModule = Module::find($module->name);
|
||||
$installedModule->enable();
|
||||
|
||||
ModuleEnabledEvent::dispatch($module);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function disable(Request $request, string $module): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$module = ModelsModule::where('name', $module)->first();
|
||||
$module->update(['enabled' => false]);
|
||||
$installedModule = Module::find($module->name);
|
||||
$installedModule->disable();
|
||||
|
||||
ModuleDisabledEvent::dispatch($module);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
268
app/Http/Controllers/Admin/Settings/DiskController.php
Normal file
268
app/Http/Controllers/Admin/Settings/DiskController.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DiskEnvironmentRequest;
|
||||
use App\Http\Resources\FileDiskResource;
|
||||
use App\Models\FileDisk;
|
||||
use App\Models\Setting;
|
||||
use App\Services\FileDiskService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DiskController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileDiskService $fileDiskService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$limit = $request->has('limit') ? $request->limit : 5;
|
||||
$disks = FileDisk::applyFilters($request->all())
|
||||
->latest()
|
||||
->paginateData($limit);
|
||||
|
||||
return FileDiskResource::collection($disks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return JsonResponse
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function store(DiskEnvironmentRequest $request): JsonResponse|FileDiskResource
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
if (! $this->fileDiskService->validateCredentials($request->credentials, $request->driver)) {
|
||||
return respondJson('invalid_credentials', 'Invalid Credentials.');
|
||||
}
|
||||
|
||||
$disk = $this->fileDiskService->create($request);
|
||||
|
||||
return new FileDiskResource($disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function update(FileDisk $disk, Request $request): JsonResponse|FileDiskResource
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$credentials = $request->credentials;
|
||||
$driver = $request->driver;
|
||||
|
||||
if ($credentials && $driver && $disk->type !== 'SYSTEM') {
|
||||
if (! $this->fileDiskService->validateCredentials($credentials, $driver)) {
|
||||
return respondJson('invalid_credentials', 'Invalid Credentials.');
|
||||
}
|
||||
|
||||
$this->fileDiskService->update($disk, $request);
|
||||
} elseif ($request->set_as_default) {
|
||||
$this->fileDiskService->setAsDefault($disk);
|
||||
}
|
||||
|
||||
return new FileDiskResource($disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function show($disk): JsonResponse
|
||||
{
|
||||
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$diskData = [];
|
||||
switch ($disk) {
|
||||
case 'local':
|
||||
// Path is relative to storage/app/.
|
||||
// e.g., "backups" resolves to storage/app/backups/ at runtime.
|
||||
$diskData = [
|
||||
'root' => '',
|
||||
];
|
||||
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
$diskData = [
|
||||
'key' => '',
|
||||
'secret' => '',
|
||||
'region' => '',
|
||||
'bucket' => '',
|
||||
'root' => '',
|
||||
];
|
||||
|
||||
break;
|
||||
|
||||
case 's3compat':
|
||||
$diskData = [
|
||||
'endpoint' => '',
|
||||
'key' => '',
|
||||
'secret' => '',
|
||||
'region' => '',
|
||||
'bucket' => '',
|
||||
'root' => '',
|
||||
];
|
||||
|
||||
case 'doSpaces':
|
||||
$diskData = [
|
||||
'key' => '',
|
||||
'secret' => '',
|
||||
'region' => '',
|
||||
'bucket' => '',
|
||||
'endpoint' => '',
|
||||
'root' => '',
|
||||
];
|
||||
|
||||
break;
|
||||
|
||||
case 'dropbox':
|
||||
$diskData = [
|
||||
'token' => '',
|
||||
'key' => '',
|
||||
'secret' => '',
|
||||
'app' => '',
|
||||
'root' => '',
|
||||
];
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data = array_merge($diskData);
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param FileDisk $taxType
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function destroy(FileDisk $disk): JsonResponse
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
if ($disk->type === 'SYSTEM') {
|
||||
return respondJson('not_allowed', 'System disks cannot be deleted.');
|
||||
}
|
||||
|
||||
if ($disk->setAsDefault()) {
|
||||
return respondJson('not_allowed', 'The default disk cannot be deleted.');
|
||||
}
|
||||
|
||||
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
|
||||
$diskName = $prefix.$disk->driver;
|
||||
$mediaCount = DB::table('media')
|
||||
->where('disk', $diskName)
|
||||
->orWhere('disk', $disk->driver)
|
||||
->count();
|
||||
|
||||
if ($mediaCount > 0) {
|
||||
return respondJson('disk_has_files', 'Cannot delete this disk — it contains '.$mediaCount.' file(s). Migrate files first.');
|
||||
}
|
||||
|
||||
$disk->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getDiskDrivers(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$drivers = [
|
||||
[
|
||||
'name' => 'Local',
|
||||
'value' => 'local',
|
||||
],
|
||||
[
|
||||
'name' => 'Amazon S3',
|
||||
'value' => 's3',
|
||||
],
|
||||
[
|
||||
'name' => 'S3 Compatible Storage',
|
||||
'value' => 's3compat',
|
||||
],
|
||||
[
|
||||
'name' => 'Digital Ocean Spaces',
|
||||
'value' => 'doSpaces',
|
||||
],
|
||||
[
|
||||
'name' => 'Dropbox',
|
||||
'value' => 'dropbox',
|
||||
],
|
||||
];
|
||||
|
||||
$defaultDisk = FileDisk::where('set_as_default', true)->first();
|
||||
|
||||
return response()->json([
|
||||
'drivers' => $drivers,
|
||||
'default' => $defaultDisk?->driver ?? 'local',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getDiskPurposes(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$defaultDisk = FileDisk::where('set_as_default', true)->first();
|
||||
$defaultId = $defaultDisk?->id;
|
||||
|
||||
return response()->json([
|
||||
'media_disk_id' => Setting::getSetting('media_disk_id') ?? $defaultId,
|
||||
'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $defaultId,
|
||||
'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $defaultId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateDiskPurposes(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage file disk');
|
||||
|
||||
$request->validate([
|
||||
'media_disk_id' => 'nullable|exists:file_disks,id',
|
||||
'pdf_disk_id' => 'nullable|exists:file_disks,id',
|
||||
'backup_disk_id' => 'nullable|exists:file_disks,id',
|
||||
]);
|
||||
|
||||
if ($request->has('media_disk_id')) {
|
||||
Setting::setSetting('media_disk_id', $request->media_disk_id);
|
||||
}
|
||||
|
||||
if ($request->has('pdf_disk_id')) {
|
||||
Setting::setSetting('pdf_disk_id', $request->pdf_disk_id);
|
||||
}
|
||||
|
||||
if ($request->has('backup_disk_id')) {
|
||||
Setting::setSetting('backup_disk_id', $request->backup_disk_id);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Admin/Settings/MailConfigurationController.php
Executable file
94
app/Http/Controllers/Admin/Settings/MailConfigurationController.php
Executable file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\MailEnvironmentRequest;
|
||||
use App\Mail\TestMail;
|
||||
use App\Models\Setting;
|
||||
use App\Services\MailConfigurationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MailConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly MailConfigurationService $mailConfigurationService) {}
|
||||
|
||||
/**
|
||||
* Save the mail environment variables
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function saveMailEnvironment(MailEnvironmentRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage email config');
|
||||
|
||||
$setting = Setting::getSetting('profile_complete');
|
||||
|
||||
$this->mailConfigurationService->saveGlobalConfig($request->validated());
|
||||
|
||||
if ($setting !== 'COMPLETED') {
|
||||
Setting::setSetting('profile_complete', 4);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => 'mail_variables_save_successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mail environment variables
|
||||
*
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getMailEnvironment(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage email config');
|
||||
|
||||
return response()->json($this->mailConfigurationService->getGlobalConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the available mail drivers
|
||||
*
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getMailDrivers(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage email config');
|
||||
|
||||
return response()->json($this->mailConfigurationService->getAvailableDrivers());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the email configuration
|
||||
*
|
||||
*
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function testEmailConfig(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage email config');
|
||||
|
||||
$this->validate($request, [
|
||||
'to' => 'required|email',
|
||||
'subject' => 'required',
|
||||
'message' => 'required',
|
||||
]);
|
||||
|
||||
Mail::to($request->to)->send(new TestMail($request->subject, $request->message));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PDFConfigurationRequest;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Setup\EnvironmentManager;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PDFConfigurationController extends Controller
|
||||
{
|
||||
protected EnvironmentManager $environmentManager;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(EnvironmentManager $environmentManager)
|
||||
{
|
||||
$this->environmentManager = $environmentManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the available drivers
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getDrivers(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage pdf config');
|
||||
|
||||
$drivers = [
|
||||
'dompdf',
|
||||
'gotenberg',
|
||||
];
|
||||
|
||||
return response()->json($drivers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the PDF settings
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getEnvironment(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage pdf config');
|
||||
|
||||
// Get PDF settings from database
|
||||
$pdfSettings = Setting::getSettings([
|
||||
'pdf_driver',
|
||||
'gotenberg_host',
|
||||
'gotenberg_papersize',
|
||||
'gotenberg_margins',
|
||||
]);
|
||||
|
||||
$config = [
|
||||
'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'),
|
||||
'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'),
|
||||
'gotenberg_margins' => $pdfSettings['gotenberg_margins'] ?? config('pdf.connections.gotenberg.margins'),
|
||||
'gotenberg_papersize' => $pdfSettings['gotenberg_papersize'] ?? config('pdf.connections.gotenberg.papersize'),
|
||||
];
|
||||
|
||||
return response()->json($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the settings
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function saveEnvironment(PDFConfigurationRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage pdf config');
|
||||
|
||||
// Prepare PDF settings for database storage
|
||||
$pdfSettings = $this->preparePDFSettingsForDatabase($request);
|
||||
|
||||
// Save PDF settings to database
|
||||
Setting::setSettings($pdfSettings);
|
||||
|
||||
return response()->json([
|
||||
'success' => 'pdf_variables_save_successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare PDF settings for database storage
|
||||
*/
|
||||
private function preparePDFSettingsForDatabase(PDFConfigurationRequest $request): array
|
||||
{
|
||||
$driver = $request->get('pdf_driver');
|
||||
|
||||
// Base settings that are always saved
|
||||
$settings = [
|
||||
'pdf_driver' => $driver,
|
||||
];
|
||||
|
||||
// Driver-specific settings
|
||||
switch ($driver) {
|
||||
case 'gotenberg':
|
||||
$settings = array_merge($settings, [
|
||||
'gotenberg_host' => $request->get('gotenberg_host'),
|
||||
'gotenberg_papersize' => $request->get('gotenberg_papersize'),
|
||||
'gotenberg_margins' => $request->get('gotenberg_margins'),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'dompdf':
|
||||
// dompdf doesn't have additional configuration in the current setup
|
||||
break;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/Admin/Settings/SettingsController.php
Normal file
35
app/Http/Controllers/Admin/Settings/SettingsController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\GetSettingRequest;
|
||||
use App\Http\Requests\SettingRequest;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function show(GetSettingRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage settings');
|
||||
|
||||
$setting = Setting::getSetting($request->key);
|
||||
|
||||
return response()->json([
|
||||
$request->key => $setting,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SettingRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage settings');
|
||||
|
||||
Setting::setSettings($request->settings);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
$request->settings,
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
app/Http/Controllers/Admin/UpdateController.php
Normal file
116
app/Http/Controllers/Admin/UpdateController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Update\Updater;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class UpdateController extends Controller
|
||||
{
|
||||
public function checkVersion(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
set_time_limit(600);
|
||||
|
||||
$channel = $request->get('channel', 'stable');
|
||||
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
|
||||
|
||||
return response()->json(Updater::checkForUpdate($version, $channel));
|
||||
}
|
||||
|
||||
public function download(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
$request->validate(['version' => 'required']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => Updater::download($request->version),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unzip(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
$request->validate(['path' => 'required']);
|
||||
|
||||
try {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => Updater::unzip($request->path),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function copy(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
$request->validate(['path' => 'required']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => Updater::copyFiles($request->path),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(Request $request): JsonResponse
|
||||
{
|
||||
return $this->clean($request);
|
||||
}
|
||||
|
||||
public function clean(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
// Backward compatibility: use deleted_files when no manifest exists
|
||||
if (! File::exists(base_path('manifest.json'))
|
||||
&& isset($request->deleted_files)
|
||||
&& ! empty($request->deleted_files)) {
|
||||
Updater::deleteFiles($request->deleted_files);
|
||||
|
||||
return response()->json(['success' => true, 'cleaned' => 0]);
|
||||
}
|
||||
|
||||
$result = Updater::cleanStaleFiles();
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function migrate(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
Updater::migrateUpdate();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function finish(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureSuperAdmin();
|
||||
|
||||
$request->validate([
|
||||
'installed' => 'required',
|
||||
'version' => 'required',
|
||||
]);
|
||||
|
||||
return response()->json(Updater::finishUpdate($request->installed, $request->version));
|
||||
}
|
||||
|
||||
private function ensureSuperAdmin(): void
|
||||
{
|
||||
$this->authorize('manage update app');
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Admin/UsersController.php
Normal file
101
app/Http/Controllers/Admin/UsersController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AdminUserUpdateRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\ImpersonationLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class UsersController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$limit = $request->has('limit') ? $request->limit : 10;
|
||||
|
||||
$users = User::with('companies')
|
||||
->applyFilters($request->all())
|
||||
->latest()
|
||||
->paginate($limit);
|
||||
|
||||
return UserResource::collection($users);
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$user->load('companies');
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
public function update(AdminUserUpdateRequest $request, User $user)
|
||||
{
|
||||
$data = $request->only(['name', 'email', 'phone']);
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = $request->password;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
public function impersonate(Request $request, User $user)
|
||||
{
|
||||
$admin = $request->user();
|
||||
|
||||
if ($admin->id === $user->id) {
|
||||
return response()->json([
|
||||
'error' => 'cannot_impersonate_self',
|
||||
'message' => 'You cannot impersonate yourself.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$token = $user->createToken(
|
||||
'impersonation-by-'.$admin->id,
|
||||
['*'],
|
||||
now()->addHours(2),
|
||||
);
|
||||
|
||||
$log = ImpersonationLog::create([
|
||||
'admin_id' => $admin->id,
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'token_id' => $token->accessToken->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token->plainTextToken,
|
||||
'impersonation_log_id' => $log->id,
|
||||
'user' => new UserResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function stopImpersonating(Request $request)
|
||||
{
|
||||
$token = $request->user()->currentAccessToken();
|
||||
|
||||
if ($token instanceof PersonalAccessToken && str_starts_with($token->name, 'impersonation-by-')) {
|
||||
$log = ImpersonationLog::where('token_id', $token->id)
|
||||
->whereNull('stopped_at')
|
||||
->first();
|
||||
|
||||
if ($log) {
|
||||
$log->update(['stopped_at' => now()]);
|
||||
}
|
||||
|
||||
$token->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => 'not_impersonating',
|
||||
'message' => 'No active impersonation session.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,20 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class AppVersionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$version = Setting::getSetting('version');
|
||||
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
|
||||
|
||||
$channel = Setting::getSetting('updater_channel');
|
||||
if (is_null($channel)) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Mobile;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Models\CompanyInvitation;
|
||||
use App\Models\User;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -14,7 +16,7 @@ class AuthController extends Controller
|
||||
{
|
||||
public function login(LoginRequest $request)
|
||||
{
|
||||
$user = User::where('email', $request->username)->first();
|
||||
$user = User::whereRaw('LOWER(email) = ?', [strtolower($request->username)])->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -22,6 +24,17 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// Auto-accept invitation if token is provided
|
||||
if ($request->has('invitation_token') && $request->invitation_token) {
|
||||
$invitation = CompanyInvitation::where('token', $request->invitation_token)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
if ($invitation) {
|
||||
app(InvitationService::class)->accept($invitation, $user);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'type' => 'Bearer',
|
||||
'token' => $user->createToken($request->device_name)->plainTextToken,
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
@@ -25,7 +27,7 @@ class ForgotPasswordController extends Controller
|
||||
* Get the response for a successful password reset link.
|
||||
*
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
* @return RedirectResponse|JsonResponse
|
||||
*/
|
||||
protected function sendResetLinkResponse(Request $request, $response)
|
||||
{
|
||||
@@ -39,7 +41,7 @@ class ForgotPasswordController extends Controller
|
||||
* Get the response for a failed password reset link.
|
||||
*
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
* @return RedirectResponse|JsonResponse
|
||||
*/
|
||||
protected function sendResetLinkFailedResponse(Request $request, $response)
|
||||
{
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CompanyInvitation;
|
||||
use App\Models\User;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvitationRegistrationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvitationService $invitationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get invitation details by token (public endpoint for registration page).
|
||||
*/
|
||||
public function details(string $token): JsonResponse
|
||||
{
|
||||
$invitation = CompanyInvitation::where('token', $token)
|
||||
->pending()
|
||||
->with(['company', 'role'])
|
||||
->first();
|
||||
|
||||
if (! $invitation) {
|
||||
return response()->json([
|
||||
'error' => 'Invitation not found or expired.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'email' => $invitation->email,
|
||||
'company_name' => $invitation->company->name,
|
||||
'role_name' => $invitation->role->title,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user and auto-accept the invitation.
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
'invitation_token' => 'required|string',
|
||||
]);
|
||||
|
||||
$invitation = CompanyInvitation::where('token', $request->invitation_token)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
if (! $invitation) {
|
||||
throw ValidationException::withMessages([
|
||||
'invitation_token' => ['Invitation not found or expired.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($invitation->email !== $request->email) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['Email does not match the invitation.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
]);
|
||||
|
||||
$user->setSettings(['language' => 'default']);
|
||||
|
||||
$this->invitationService->accept($invitation, $user);
|
||||
|
||||
return response()->json([
|
||||
'type' => 'Bearer',
|
||||
'token' => $user->createToken('web')->plainTextToken,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
@@ -61,10 +61,14 @@ class RegisterController extends Controller
|
||||
*/
|
||||
protected function create(array $data)
|
||||
{
|
||||
return User::create([
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
]);
|
||||
|
||||
$user->setSettings(['language' => 'default']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -35,7 +38,7 @@ class ResetPasswordController extends Controller
|
||||
* Get the response for a successful password reset.
|
||||
*
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
* @return RedirectResponse|JsonResponse
|
||||
*/
|
||||
protected function sendResetResponse(Request $request, $response)
|
||||
{
|
||||
@@ -47,7 +50,7 @@ class ResetPasswordController extends Controller
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\CanResetPassword $user
|
||||
* @param CanResetPassword $user
|
||||
* @param string $password
|
||||
* @return void
|
||||
*/
|
||||
@@ -66,7 +69,7 @@ class ResetPasswordController extends Controller
|
||||
* Get the response for a failed password reset.
|
||||
*
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
* @return RedirectResponse|JsonResponse
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, $response)
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Auth;
|
||||
namespace App\Http\Controllers\Company\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Config;
|
||||
namespace App\Http\Controllers\Company\Config;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class FiscalYearsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Config;
|
||||
namespace App\Http\Controllers\Company\Config;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class LanguagesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Config;
|
||||
namespace App\Http\Controllers\Company\Config;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class RetrospectiveEditsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
@@ -1,19 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\CustomField;
|
||||
namespace App\Http\Controllers\Company\CustomField;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\CustomFieldRequest;
|
||||
use App\Http\Resources\CustomFieldResource;
|
||||
use App\Models\CustomField;
|
||||
use App\Services\CustomFieldService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class CustomFieldsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomFieldService $customFieldService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -33,13 +39,13 @@ class CustomFieldsController extends Controller
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\CustomFieldRequest $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function store(CustomFieldRequest $request)
|
||||
{
|
||||
$this->authorize('create', CustomField::class);
|
||||
|
||||
$customField = CustomField::createCustomField($request);
|
||||
$customField = $this->customFieldService->create($request);
|
||||
|
||||
return new CustomFieldResource($customField);
|
||||
}
|
||||
@@ -48,7 +54,7 @@ class CustomFieldsController extends Controller
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(CustomField $customField)
|
||||
{
|
||||
@@ -60,15 +66,15 @@ class CustomFieldsController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param Request $request
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function update(CustomFieldRequest $request, CustomField $customField)
|
||||
{
|
||||
$this->authorize('update', $customField);
|
||||
|
||||
$customField->updateCustomField($request);
|
||||
$this->customFieldService->update($customField, $request);
|
||||
|
||||
return new CustomFieldResource($customField);
|
||||
}
|
||||
@@ -77,7 +83,7 @@ class CustomFieldsController extends Controller
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(CustomField $customField)
|
||||
{
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CustomerResource;
|
||||
use App\Models\Customer;
|
||||
use App\Services\CustomerService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomerStatsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerService $customerService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, Customer $customer)
|
||||
{
|
||||
$this->authorize('view', $customer);
|
||||
|
||||
$chartData = $this->customerService->getStats(
|
||||
$customer,
|
||||
$request->header('company'),
|
||||
$request->has('previous_year')
|
||||
);
|
||||
|
||||
$customer = Customer::find($customer->id);
|
||||
|
||||
return (new CustomerResource($customer))
|
||||
->additional(['meta' => [
|
||||
'chartData' => $chartData,
|
||||
]]);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Customer;
|
||||
namespace App\Http\Controllers\Company\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests;
|
||||
use App\Http\Requests\DeleteCustomersRequest;
|
||||
use App\Http\Resources\CustomerResource;
|
||||
use App\Models\Customer;
|
||||
use App\Services\CustomerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CustomersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerService $customerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -26,13 +31,8 @@ class CustomersController extends Controller
|
||||
$customers = Customer::with('creator')
|
||||
->whereCompany()
|
||||
->applyFilters($request->all())
|
||||
->select(
|
||||
'customers.*',
|
||||
DB::raw('sum(invoices.base_due_amount) as base_due_amount'),
|
||||
DB::raw('sum(invoices.due_amount) as due_amount'),
|
||||
)
|
||||
->groupBy('customers.id')
|
||||
->leftJoin('invoices', 'customers.id', '=', 'invoices.customer_id')
|
||||
->withSum('invoices as base_due_amount', 'base_due_amount')
|
||||
->withSum('invoices as due_amount', 'due_amount')
|
||||
->paginateData($limit);
|
||||
|
||||
return CustomerResource::collection($customers)
|
||||
@@ -44,14 +44,14 @@ class CustomersController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(Requests\CustomerRequest $request)
|
||||
{
|
||||
$this->authorize('create', Customer::class);
|
||||
|
||||
$customer = Customer::createCustomer($request);
|
||||
$customer = $this->customerService->create($request);
|
||||
|
||||
return new CustomerResource($customer);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class CustomersController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Customer $customer)
|
||||
{
|
||||
@@ -71,18 +71,14 @@ class CustomersController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(Requests\CustomerRequest $request, Customer $customer)
|
||||
{
|
||||
$this->authorize('update', $customer);
|
||||
|
||||
$customer = Customer::updateCustomer($request, $customer);
|
||||
|
||||
if (is_string($customer)) {
|
||||
return respondJson('you_cannot_edit_currency', 'Cannot change currency once transactions created');
|
||||
}
|
||||
$customer = $this->customerService->update($request, $customer);
|
||||
|
||||
return new CustomerResource($customer);
|
||||
}
|
||||
@@ -90,14 +86,18 @@ class CustomersController extends Controller
|
||||
/**
|
||||
* Remove a list of Customers along side all their resources (ie. Estimates, Invoices, Payments and Addresses)
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function delete(DeleteCustomersRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple customers');
|
||||
|
||||
Customer::deleteCustomers($request->ids);
|
||||
$ids = Customer::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
$this->customerService->delete($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Dashboard;
|
||||
namespace App\Http\Controllers\Company\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
@@ -11,6 +11,7 @@ use App\Models\Expense;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
@@ -19,7 +20,7 @@ class DashboardController extends Controller
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
@@ -59,39 +60,27 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
while ($monthCounter < 12) {
|
||||
array_push(
|
||||
$invoice_totals,
|
||||
Invoice::whereBetween(
|
||||
'invoice_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_total')
|
||||
);
|
||||
array_push(
|
||||
$expense_totals,
|
||||
Expense::whereBetween(
|
||||
'expense_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_amount')
|
||||
);
|
||||
array_push(
|
||||
$receipt_totals,
|
||||
Payment::whereBetween(
|
||||
'payment_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_amount')
|
||||
);
|
||||
array_push(
|
||||
$net_income_totals,
|
||||
($receipt_totals[$i] - $expense_totals[$i])
|
||||
);
|
||||
$invoice_totals[] = Invoice::whereBetween(
|
||||
'invoice_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_total');
|
||||
$expense_totals[] = Expense::whereBetween(
|
||||
'expense_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
$receipt_totals[] = Payment::whereBetween(
|
||||
'payment_date',
|
||||
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
||||
)
|
||||
->whereCompany()
|
||||
->sum('base_amount');
|
||||
$net_income_totals[] = ($receipt_totals[$i] - $expense_totals[$i]);
|
||||
$i++;
|
||||
array_push($months, $start->translatedFormat('M'));
|
||||
$months[] = $start->translatedFormat('M');
|
||||
$monthCounter++;
|
||||
$end->startOfMonth();
|
||||
$start->addMonth()->startOfMonth();
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Estimate;
|
||||
namespace App\Http\Controllers\Company\Estimate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Estimate;
|
||||
use App\Space\PdfTemplateUtils;
|
||||
use App\Services\Pdf\PdfTemplateUtils;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
141
app/Http/Controllers/Company/Estimate/EstimatesController.php
Normal file
141
app/Http/Controllers/Company/Estimate/EstimatesController.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Estimate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeleteEstimatesRequest;
|
||||
use App\Http\Requests\EstimatesRequest;
|
||||
use App\Http\Requests\SendEstimatesRequest;
|
||||
use App\Http\Resources\EstimateResource;
|
||||
use App\Http\Resources\InvoiceResource;
|
||||
use App\Jobs\GenerateEstimatePdfJob;
|
||||
use App\Models\Estimate;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\EstimateService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Mail\Markdown;
|
||||
|
||||
class EstimatesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EstimateService $estimateService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Estimate::class);
|
||||
|
||||
$limit = $request->has('limit') ? $request->limit : 10;
|
||||
|
||||
$estimates = Estimate::whereCompany()
|
||||
->join('customers', 'customers.id', '=', 'estimates.customer_id')
|
||||
->applyFilters($request->all())
|
||||
->select('estimates.*', 'customers.name')
|
||||
->latest()
|
||||
->paginateData($limit);
|
||||
|
||||
return EstimateResource::collection($estimates)
|
||||
->additional(['meta' => [
|
||||
'estimate_total_count' => Estimate::whereCompany()->count(),
|
||||
]]);
|
||||
}
|
||||
|
||||
public function store(EstimatesRequest $request)
|
||||
{
|
||||
$this->authorize('create', Estimate::class);
|
||||
|
||||
$estimate = $this->estimateService->create($request);
|
||||
|
||||
if ($request->has('estimateSend')) {
|
||||
$this->estimateService->send($estimate, $request->only(['title', 'body']));
|
||||
}
|
||||
|
||||
GenerateEstimatePdfJob::dispatch($estimate);
|
||||
|
||||
return new EstimateResource($estimate);
|
||||
}
|
||||
|
||||
public function show(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('view', $estimate);
|
||||
|
||||
return new EstimateResource($estimate);
|
||||
}
|
||||
|
||||
public function update(EstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('update', $estimate);
|
||||
|
||||
$estimate = $this->estimateService->update($estimate, $request);
|
||||
|
||||
GenerateEstimatePdfJob::dispatch($estimate, true);
|
||||
|
||||
return new EstimateResource($estimate);
|
||||
}
|
||||
|
||||
public function delete(DeleteEstimatesRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple estimates');
|
||||
|
||||
$ids = Estimate::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
Estimate::destroy($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(SendEstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
$response = $this->estimateService->send($estimate, $request->all());
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function sendPreview(SendEstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
$markdown = new Markdown(view(), config('mail.markdown'));
|
||||
|
||||
$data = $this->estimateService->sendEstimateData($estimate, $request->all());
|
||||
$data['url'] = $estimate->estimatePdfUrl;
|
||||
|
||||
return $markdown->render('emails.send.estimate', ['data' => $data]);
|
||||
}
|
||||
|
||||
public function clone(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('view', $estimate);
|
||||
$this->authorize('create', Estimate::class);
|
||||
|
||||
$newEstimate = $this->estimateService->clone($estimate);
|
||||
|
||||
return new EstimateResource($newEstimate);
|
||||
}
|
||||
|
||||
public function convertToInvoice(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('create', Invoice::class);
|
||||
|
||||
$invoice = $this->estimateService->convertToInvoice($estimate);
|
||||
|
||||
return new InvoiceResource($invoice);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
$this->estimateService->changeStatus($estimate, $request->status);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\ExchangeRate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\BulkExchangeRateRequest;
|
||||
use App\Http\Requests\ExchangeRateProviderRequest;
|
||||
use App\Http\Resources\ExchangeRateProviderResource;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Estimate;
|
||||
use App\Models\ExchangeRateLog;
|
||||
use App\Models\ExchangeRateProvider;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Tax;
|
||||
use App\Services\ExchangeRateProviderService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ExchangeRateProviderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangeRateProviderService $exchangeRateProviderService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', ExchangeRateProvider::class);
|
||||
|
||||
$limit = $request->has('limit') ? $request->limit : 5;
|
||||
|
||||
$exchangeRateProviders = ExchangeRateProvider::whereCompany()->paginate($limit);
|
||||
|
||||
return ExchangeRateProviderResource::collection($exchangeRateProviders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(ExchangeRateProviderRequest $request)
|
||||
{
|
||||
$this->authorize('create', ExchangeRateProvider::class);
|
||||
|
||||
$query = $this->exchangeRateProviderService->checkActiveCurrencies($request);
|
||||
|
||||
if (count($query) !== 0) {
|
||||
return respondJson('currency_used', 'Currency used.');
|
||||
}
|
||||
|
||||
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
|
||||
|
||||
if ($checkConverterApi->status() == 200) {
|
||||
$exchangeRateProvider = $this->exchangeRateProviderService->create($request);
|
||||
|
||||
return new ExchangeRateProviderResource($exchangeRateProvider);
|
||||
}
|
||||
|
||||
return $checkConverterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function show(ExchangeRateProvider $exchangeRateProvider)
|
||||
{
|
||||
$this->authorize('view', $exchangeRateProvider);
|
||||
|
||||
return new ExchangeRateProviderResource($exchangeRateProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function update(ExchangeRateProviderRequest $request, ExchangeRateProvider $exchangeRateProvider)
|
||||
{
|
||||
$this->authorize('update', $exchangeRateProvider);
|
||||
|
||||
$query = $this->exchangeRateProviderService->checkUpdateActiveCurrencies($exchangeRateProvider, $request);
|
||||
|
||||
if (count($query) !== 0) {
|
||||
return respondJson('currency_used', 'Currency used.');
|
||||
}
|
||||
|
||||
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
|
||||
|
||||
if ($checkConverterApi->status() == 200) {
|
||||
$this->exchangeRateProviderService->update($exchangeRateProvider, $request);
|
||||
|
||||
return new ExchangeRateProviderResource($exchangeRateProvider);
|
||||
}
|
||||
|
||||
return $checkConverterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(ExchangeRateProvider $exchangeRateProvider)
|
||||
{
|
||||
$this->authorize('delete', $exchangeRateProvider);
|
||||
|
||||
if ($exchangeRateProvider->active == true) {
|
||||
return respondJson('provider_active', 'Provider Active.');
|
||||
}
|
||||
|
||||
$exchangeRateProvider->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function activeProvider(Request $request, Currency $currency)
|
||||
{
|
||||
$query = ExchangeRateProvider::whereCompany()->whereJsonContains('currencies', $currency->code)
|
||||
->where('active', true)
|
||||
->get();
|
||||
|
||||
if (count($query) !== 0) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'provider_active',
|
||||
], 200);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => 'no_active_provider',
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function getRate(Request $request, Currency $currency)
|
||||
{
|
||||
$settings = CompanySetting::getSettings(['currency'], $request->header('company'));
|
||||
$baseCurrency = Currency::findOrFail($settings['currency']);
|
||||
|
||||
$query = ExchangeRateProvider::whereJsonContains('currencies', $currency->code)
|
||||
->where('active', true)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$exchangeRate = ExchangeRateLog::where('base_currency_id', $currency->id)
|
||||
->where('currency_id', $baseCurrency->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->value('exchange_rate');
|
||||
|
||||
if ($query) {
|
||||
$filter = Arr::only($query[0], ['key', 'driver', 'driver_config']);
|
||||
$result = $this->exchangeRateProviderService->getExchangeRate(
|
||||
$filter['driver'],
|
||||
$filter['key'],
|
||||
$filter['driver_config'] ?? [],
|
||||
$currency->code,
|
||||
$baseCurrency->code
|
||||
);
|
||||
|
||||
if ($result->status() == 200) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
if ($exchangeRate) {
|
||||
return response()->json([
|
||||
'exchangeRate' => [$exchangeRate],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => 'no_exchange_rate_available',
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function supportedCurrencies(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', ExchangeRateProvider::class);
|
||||
|
||||
return $this->exchangeRateProviderService->getSupportedCurrencies(
|
||||
$request->driver,
|
||||
$request->key,
|
||||
$request->driver_config ?? []
|
||||
);
|
||||
}
|
||||
|
||||
public function usedCurrencies(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', ExchangeRateProvider::class);
|
||||
|
||||
$providerId = $request->provider_id;
|
||||
|
||||
$activeExchangeRateProviders = ExchangeRateProvider::where('active', true)
|
||||
->whereCompany()
|
||||
->when($providerId, function ($query) use ($providerId) {
|
||||
return $query->where('id', '<>', $providerId);
|
||||
})
|
||||
->pluck('currencies');
|
||||
$activeExchangeRateProvider = [];
|
||||
|
||||
foreach ($activeExchangeRateProviders as $data) {
|
||||
if (is_array($data)) {
|
||||
for ($limit = 0; $limit < count($data); $limit++) {
|
||||
$activeExchangeRateProvider[] = $data[$limit];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allExchangeRateProviders = ExchangeRateProvider::whereCompany()->pluck('currencies');
|
||||
$allExchangeRateProvider = [];
|
||||
|
||||
foreach ($allExchangeRateProviders as $data) {
|
||||
if (is_array($data)) {
|
||||
for ($limit = 0; $limit < count($data); $limit++) {
|
||||
$allExchangeRateProvider[] = $data[$limit];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'allUsedCurrencies' => $allExchangeRateProvider ? $allExchangeRateProvider : [],
|
||||
'activeUsedCurrencies' => $activeExchangeRateProvider ? $activeExchangeRateProvider : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function usedCurrenciesWithoutRate(Request $request)
|
||||
{
|
||||
$invoices = Invoice::where('exchange_rate', null)->pluck('currency_id')->toArray();
|
||||
$taxes = Tax::where('exchange_rate', null)->pluck('currency_id')->toArray();
|
||||
$estimates = Estimate::where('exchange_rate', null)->pluck('currency_id')->toArray();
|
||||
$payments = Payment::where('exchange_rate', null)->pluck('currency_id')->toArray();
|
||||
|
||||
$currencies = array_merge($invoices, $taxes, $estimates, $payments);
|
||||
|
||||
return response()->json([
|
||||
'currencies' => Currency::whereIn('id', $currencies)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkUpdate(BulkExchangeRateRequest $request)
|
||||
{
|
||||
$bulkExchangeRate = CompanySetting::getSetting('bulk_exchange_rate_configured', $request->header('company'));
|
||||
|
||||
if ($bulkExchangeRate == 'NO') {
|
||||
if ($request->currencies) {
|
||||
foreach ($request->currencies as $currency) {
|
||||
$currency['exchange_rate'] = $currency['exchange_rate'] ?? 1;
|
||||
|
||||
$invoices = Invoice::where('currency_id', $currency['id'])->get();
|
||||
|
||||
if ($invoices) {
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoice->update([
|
||||
'exchange_rate' => $currency['exchange_rate'],
|
||||
'base_discount_val' => $invoice->sub_total * $currency['exchange_rate'],
|
||||
'base_sub_total' => $invoice->sub_total * $currency['exchange_rate'],
|
||||
'base_total' => $invoice->total * $currency['exchange_rate'],
|
||||
'base_tax' => $invoice->tax * $currency['exchange_rate'],
|
||||
'base_due_amount' => $invoice->due_amount * $currency['exchange_rate'],
|
||||
]);
|
||||
|
||||
$this->updateItemsExchangeRate($invoice);
|
||||
}
|
||||
}
|
||||
|
||||
$estimates = Estimate::where('currency_id', $currency['id'])->get();
|
||||
|
||||
if ($estimates) {
|
||||
foreach ($estimates as $estimate) {
|
||||
$estimate->update([
|
||||
'exchange_rate' => $currency['exchange_rate'],
|
||||
'base_discount_val' => $estimate->sub_total * $currency['exchange_rate'],
|
||||
'base_sub_total' => $estimate->sub_total * $currency['exchange_rate'],
|
||||
'base_total' => $estimate->total * $currency['exchange_rate'],
|
||||
'base_tax' => $estimate->tax * $currency['exchange_rate'],
|
||||
]);
|
||||
|
||||
$this->updateItemsExchangeRate($estimate);
|
||||
}
|
||||
}
|
||||
|
||||
$taxes = Tax::where('currency_id', $currency['id'])->get();
|
||||
|
||||
if ($taxes) {
|
||||
foreach ($taxes as $tax) {
|
||||
$tax->base_amount = $tax->base_amount * $currency['exchange_rate'];
|
||||
$tax->save();
|
||||
}
|
||||
}
|
||||
|
||||
$payments = Payment::where('currency_id', $currency['id'])->get();
|
||||
|
||||
if ($payments) {
|
||||
foreach ($payments as $payment) {
|
||||
$payment->exchange_rate = $currency['exchange_rate'];
|
||||
$payment->base_amount = $payment->amount * $currency['exchange_rate'];
|
||||
$payment->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'bulk_exchange_rate_configured' => 'YES',
|
||||
];
|
||||
|
||||
CompanySetting::setSettings($settings, $request->header('company'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateItemsExchangeRate($model): void
|
||||
{
|
||||
foreach ($model->items as $item) {
|
||||
$item->update([
|
||||
'exchange_rate' => $model->exchange_rate,
|
||||
'base_discount_val' => $item->discount_val * $model->exchange_rate,
|
||||
'base_price' => $item->price * $model->exchange_rate,
|
||||
'base_tax' => $item->tax * $model->exchange_rate,
|
||||
'base_total' => $item->total * $model->exchange_rate,
|
||||
]);
|
||||
|
||||
$this->updateTaxesExchangeRate($item);
|
||||
}
|
||||
|
||||
$this->updateTaxesExchangeRate($model);
|
||||
}
|
||||
|
||||
private function updateTaxesExchangeRate($model): void
|
||||
{
|
||||
if ($model->taxes()->exists()) {
|
||||
$model->taxes->map(function ($tax) use ($model) {
|
||||
$tax->update([
|
||||
'exchange_rate' => $model->exchange_rate,
|
||||
'base_amount' => $tax->amount * $model->exchange_rate,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Expense;
|
||||
namespace App\Http\Controllers\Company\Expense;
|
||||
|
||||
use App\ExpensesCategory;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ExpenseCategoryRequest;
|
||||
use App\Http\Resources\ExpenseCategoryResource;
|
||||
use App\Models\ExpenseCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ExpenseCategoriesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -32,8 +34,8 @@ class ExpenseCategoriesController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(ExpenseCategoryRequest $request)
|
||||
{
|
||||
@@ -47,7 +49,7 @@ class ExpenseCategoriesController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(ExpenseCategory $category)
|
||||
{
|
||||
@@ -59,9 +61,9 @@ class ExpenseCategoriesController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \App\Models\ExpenseCategory $ExpenseCategory
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @param ExpenseCategory $ExpenseCategory
|
||||
* @return Response
|
||||
*/
|
||||
public function update(ExpenseCategoryRequest $request, ExpenseCategory $category)
|
||||
{
|
||||
@@ -75,8 +77,8 @@ class ExpenseCategoriesController extends Controller
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param \App\ExpensesCategory $category
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param ExpensesCategory $category
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(ExpenseCategory $category)
|
||||
{
|
||||
158
app/Http/Controllers/Company/Expense/ExpensesController.php
Normal file
158
app/Http/Controllers/Company/Expense/ExpensesController.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Expense;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeleteExpensesRequest;
|
||||
use App\Http\Requests\ExpenseRequest;
|
||||
use App\Http\Requests\UploadExpenseReceiptRequest;
|
||||
use App\Http\Resources\ExpenseResource;
|
||||
use App\Models\Expense;
|
||||
use App\Services\ExpenseService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ExpensesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExpenseService $expenseService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Expense::class);
|
||||
|
||||
$limit = $request->has('limit') ? $request->limit : 10;
|
||||
|
||||
$expenses = Expense::with('category', 'creator', 'fields')
|
||||
->whereCompany()
|
||||
->leftJoin('customers', 'customers.id', '=', 'expenses.customer_id')
|
||||
->join('expense_categories', 'expense_categories.id', '=', 'expenses.expense_category_id')
|
||||
->applyFilters($request->all())
|
||||
->select('expenses.*', 'expense_categories.name', 'customers.name as user_name')
|
||||
->paginateData($limit);
|
||||
|
||||
return ExpenseResource::collection($expenses)
|
||||
->additional(['meta' => [
|
||||
'expense_total_count' => Expense::whereCompany()->count(),
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(ExpenseRequest $request)
|
||||
{
|
||||
$this->authorize('create', Expense::class);
|
||||
|
||||
$expense = $this->expenseService->create($request);
|
||||
|
||||
return new ExpenseResource($expense);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Expense $expense)
|
||||
{
|
||||
$this->authorize('view', $expense);
|
||||
|
||||
return new ExpenseResource($expense);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(ExpenseRequest $request, Expense $expense)
|
||||
{
|
||||
$this->authorize('update', $expense);
|
||||
|
||||
$this->expenseService->update($expense, $request);
|
||||
|
||||
return new ExpenseResource($expense);
|
||||
}
|
||||
|
||||
public function delete(DeleteExpensesRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple expenses');
|
||||
|
||||
$ids = Expense::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
Expense::destroy($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function showReceipt(Expense $expense)
|
||||
{
|
||||
$this->authorize('view', $expense);
|
||||
|
||||
if ($expense) {
|
||||
$media = $expense->getFirstMedia('receipts');
|
||||
|
||||
if ($media) {
|
||||
return response()->file($media->getPath());
|
||||
}
|
||||
|
||||
return respondJson('receipt_does_not_exist', 'Receipt does not exist.');
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadReceipt(UploadExpenseReceiptRequest $request, Expense $expense)
|
||||
{
|
||||
$this->authorize('update', $expense);
|
||||
|
||||
$data = json_decode($request->attachment_receipt);
|
||||
|
||||
if ($data) {
|
||||
if ($request->type === 'edit') {
|
||||
$expense->clearMediaCollection('receipts');
|
||||
}
|
||||
|
||||
$expense->addMediaFromBase64($data->data)
|
||||
->usingFileName($data->name)
|
||||
->toMediaCollection('receipts');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => 'Expense receipts uploaded successfully',
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function downloadReceipt(Expense $expense)
|
||||
{
|
||||
$this->authorize('view', $expense);
|
||||
|
||||
if ($expense) {
|
||||
$media = $expense->getFirstMedia('receipts');
|
||||
if ($media) {
|
||||
$imagePath = $media->getPath();
|
||||
$response = \Response::download($imagePath, $media->file_name);
|
||||
if (ob_get_contents()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => 'receipt_not_found',
|
||||
]);
|
||||
}
|
||||
}
|
||||
156
app/Http/Controllers/Company/General/BootstrapController.php
Normal file
156
app/Http/Controllers/Company/General/BootstrapController.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CompanyInvitationResource;
|
||||
use App\Http\Resources\CompanyResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyInvitation;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Module;
|
||||
use App\Models\Setting;
|
||||
use App\Traits\GeneratesMenuTrait;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
class BootstrapController extends Controller
|
||||
{
|
||||
use GeneratesMenuTrait;
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$current_user = $request->user();
|
||||
$current_user_settings = $current_user->getAllSettings();
|
||||
$companies = $current_user->companies;
|
||||
|
||||
$pendingInvitations = CompanyInvitation::forUser($current_user)
|
||||
->pending()
|
||||
->with(['company', 'role', 'invitedBy'])
|
||||
->get();
|
||||
|
||||
$global_settings = Setting::getSettings([
|
||||
'api_token',
|
||||
'admin_portal_theme',
|
||||
'admin_portal_logo',
|
||||
'login_page_logo',
|
||||
'login_page_heading',
|
||||
'login_page_description',
|
||||
'admin_page_title',
|
||||
'copyright_text',
|
||||
'save_pdf_to_disk',
|
||||
'show_sidebar_group_labels',
|
||||
]);
|
||||
|
||||
// Super admin mode — return admin-only menu with all companies listed
|
||||
if ($current_user->isSuperAdmin() && $request->has('admin_mode')) {
|
||||
return response()->json([
|
||||
'current_user' => new UserResource($current_user),
|
||||
'current_user_settings' => $current_user_settings,
|
||||
'current_user_abilities' => [],
|
||||
'companies' => CompanyResource::collection($companies),
|
||||
'current_company' => null,
|
||||
'current_company_settings' => [],
|
||||
'current_company_currency' => Currency::first(),
|
||||
'config' => config('invoiceshelf'),
|
||||
'global_settings' => $global_settings,
|
||||
'main_menu' => $this->generateMenu('admin_menu', $current_user),
|
||||
'setting_menu' => [],
|
||||
'modules' => [],
|
||||
'admin_mode' => true,
|
||||
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
|
||||
]);
|
||||
}
|
||||
|
||||
// User has no companies — return minimal bootstrap
|
||||
if ($companies->isEmpty()) {
|
||||
return response()->json([
|
||||
'current_user' => new UserResource($current_user),
|
||||
'current_user_settings' => $current_user_settings,
|
||||
'current_user_abilities' => [],
|
||||
'companies' => [],
|
||||
'current_company' => null,
|
||||
'current_company_settings' => [],
|
||||
'current_company_currency' => Currency::first(),
|
||||
'config' => config('invoiceshelf'),
|
||||
'global_settings' => $global_settings,
|
||||
'main_menu' => [],
|
||||
'setting_menu' => [],
|
||||
'modules' => [],
|
||||
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
|
||||
]);
|
||||
}
|
||||
|
||||
$main_menu = $this->generateMenu('main_menu', $current_user);
|
||||
$setting_menu = $this->generateMenu('setting_menu', $current_user);
|
||||
|
||||
// Merge module-registered menu items into the main menu so they
|
||||
// participate in the unified group + priority ordering.
|
||||
foreach (ModuleRegistry::allMenu() as $slug => $item) {
|
||||
$main_menu[] = [
|
||||
'title' => __($item['title']),
|
||||
'link' => $item['link'],
|
||||
'icon' => $item['icon'],
|
||||
'name' => 'module-'.$slug,
|
||||
'group' => $item['group'] ?? 'modules',
|
||||
'group_label' => $item['group_label'] ?? 'navigation.modules',
|
||||
'priority' => $item['priority'] ?? 100,
|
||||
];
|
||||
}
|
||||
|
||||
$current_company = Company::find($request->header('company'));
|
||||
|
||||
if ((! $current_company) || ($current_company && ! $current_user->hasCompany($current_company->id))) {
|
||||
$current_company = $current_user->companies()->first();
|
||||
}
|
||||
|
||||
$current_company_settings = CompanySetting::getAllSettings($current_company->id);
|
||||
|
||||
$current_company_currency = $current_company_settings->has('currency')
|
||||
? Currency::find($current_company_settings->get('currency'))
|
||||
: Currency::first();
|
||||
|
||||
BouncerFacade::refreshFor($current_user);
|
||||
|
||||
return response()->json([
|
||||
'current_user' => new UserResource($current_user),
|
||||
'current_user_settings' => $current_user_settings,
|
||||
'current_user_abilities' => $current_user->getAbilities(),
|
||||
'companies' => CompanyResource::collection($companies),
|
||||
'current_company' => new CompanyResource($current_company),
|
||||
'current_company_settings' => $current_company_settings,
|
||||
'current_company_currency' => $current_company_currency,
|
||||
'config' => config('invoiceshelf'),
|
||||
'global_settings' => $global_settings,
|
||||
'main_menu' => $main_menu,
|
||||
'setting_menu' => $setting_menu,
|
||||
'modules' => Module::where('enabled', true)->pluck('name'),
|
||||
'user_menu' => collect(ModuleRegistry::allUserMenu())
|
||||
->map(fn (array $item, string $slug) => [
|
||||
...$item,
|
||||
'title' => __($item['title']),
|
||||
'name' => 'module-'.$slug,
|
||||
])
|
||||
->sortBy('priority')
|
||||
->values()
|
||||
->all(),
|
||||
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
|
||||
]);
|
||||
}
|
||||
|
||||
public function currentCompany(Request $request)
|
||||
{
|
||||
$company = Company::find($request->header('company'));
|
||||
|
||||
return new CompanyResource($company);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Company/General/ConfigController.php
Normal file
49
app/Http/Controllers/Company/General/ConfigController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
class ConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->key === 'exchange_rate_drivers') {
|
||||
return response()->json([
|
||||
'exchange_rate_drivers' => $this->exchangeRateDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
$request->key => config('invoiceshelf.'.$request->key),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exchange rate driver list from the module Registry.
|
||||
*
|
||||
* Returns enriched objects (with label, website, and config_fields) so the
|
||||
* frontend can render driver-specific configuration forms without hardcoding
|
||||
* any per-driver UI.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function exchangeRateDrivers(): array
|
||||
{
|
||||
return collect(Registry::allDrivers('exchange_rate'))
|
||||
->map(fn (array $meta, string $name) => [
|
||||
'value' => $name,
|
||||
'label' => $meta['label'] ?? $name,
|
||||
'website' => $meta['website'] ?? '',
|
||||
'config_fields' => $meta['config_fields'] ?? [],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Company/General/FormatsController.php
Normal file
33
app/Http/Controllers/Company/General/FormatsController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Formatters\DateFormatter;
|
||||
use App\Support\Formatters\TimeFormatter;
|
||||
use App\Support\Formatters\TimeZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class FormatsController extends Controller
|
||||
{
|
||||
public function dateFormats(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'date_formats' => DateFormatter::get_list(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function timeFormats(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'time_formats' => TimeFormatter::get_list(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function timezones(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'time_zones' => TimeZones::get_list(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CompanyInvitationResource;
|
||||
use App\Models\CompanyInvitation;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvitationResponseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvitationService $invitationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get pending invitations for the authenticated user.
|
||||
*/
|
||||
public function pending(Request $request): JsonResponse
|
||||
{
|
||||
$invitations = $this->invitationService->getPendingForUser($request->user());
|
||||
|
||||
return response()->json([
|
||||
'invitations' => CompanyInvitationResource::collection($invitations),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a company invitation.
|
||||
*/
|
||||
public function accept(Request $request, CompanyInvitation $invitation): JsonResponse
|
||||
{
|
||||
$this->invitationService->accept($invitation, $request->user());
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a company invitation.
|
||||
*/
|
||||
public function decline(Request $request, CompanyInvitation $invitation): JsonResponse
|
||||
{
|
||||
$this->invitationService->decline($invitation, $request->user());
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\General;
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\NotesRequest;
|
||||
use App\Http\Resources\NoteResource;
|
||||
use App\Models\Note;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class NotesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -32,8 +33,8 @@ class NotesController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(NotesRequest $request)
|
||||
{
|
||||
@@ -41,13 +42,22 @@ class NotesController extends Controller
|
||||
|
||||
$note = Note::create($request->getNotesPayload());
|
||||
|
||||
if ($note->is_default) {
|
||||
Note::where('id', '!=', $note->id)
|
||||
->where('type', $note->type)
|
||||
->where('is_default', true)
|
||||
->update([
|
||||
'is_default' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return new NoteResource($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(Note $note)
|
||||
{
|
||||
@@ -59,8 +69,8 @@ class NotesController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function update(NotesRequest $request, Note $note)
|
||||
{
|
||||
@@ -68,13 +78,22 @@ class NotesController extends Controller
|
||||
|
||||
$note->update($request->getNotesPayload());
|
||||
|
||||
if ($note->is_default) {
|
||||
Note::where('id', '!=', $note->id)
|
||||
->where('type', $note->type)
|
||||
->where('is_default', true)
|
||||
->update([
|
||||
'is_default' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return new NoteResource($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(Note $note)
|
||||
{
|
||||
@@ -1,18 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\General;
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
@@ -24,7 +25,8 @@ class SearchController extends Controller
|
||||
->paginate(10);
|
||||
|
||||
if ($user->isOwner()) {
|
||||
$users = User::applyFilters($request->only(['search']))
|
||||
$users = User::whereCompany()
|
||||
->applyFilters($request->only(['search']))
|
||||
->latest()
|
||||
->paginate(10);
|
||||
}
|
||||
@@ -34,4 +36,15 @@ class SearchController extends Controller
|
||||
'users' => $users ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function users(Request $request)
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$users = User::whereEmail($request->email)
|
||||
->latest()
|
||||
->paginate(10);
|
||||
|
||||
return response()->json(['users' => $users]);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\General;
|
||||
namespace App\Http\Controllers\Company\General;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Estimate;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Services\SerialNumberFormatter;
|
||||
use App\Services\SerialNumberService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NextNumberController extends Controller
|
||||
class SerialNumberController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function __invoke(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment)
|
||||
public function nextNumber(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment): JsonResponse
|
||||
{
|
||||
$key = $request->key;
|
||||
$nextNumber = null;
|
||||
$serial = (new SerialNumberFormatter)
|
||||
$serial = (new SerialNumberService)
|
||||
->setCompany($request->header('company'))
|
||||
->setCustomer($request->userId);
|
||||
|
||||
@@ -48,7 +44,9 @@ class NextNumberController extends Controller
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return response()->json([
|
||||
@@ -62,4 +60,18 @@ class NextNumberController extends Controller
|
||||
'nextNumber' => $nextNumber,
|
||||
]);
|
||||
}
|
||||
|
||||
public function placeholders(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->input('format')) {
|
||||
$placeholders = SerialNumberService::getPlaceholders($request->input('format'));
|
||||
} else {
|
||||
$placeholders = [];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'placeholders' => $placeholders,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Invoice;
|
||||
namespace App\Http\Controllers\Company\Invoice;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Space\PdfTemplateUtils;
|
||||
use App\Services\Pdf\PdfTemplateUtils;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
171
app/Http/Controllers/Company/Invoice/InvoicesController.php
Normal file
171
app/Http/Controllers/Company/Invoice/InvoicesController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Invoice;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests;
|
||||
use App\Http\Requests\DeleteInvoiceRequest;
|
||||
use App\Http\Requests\SendInvoiceRequest;
|
||||
use App\Http\Resources\EstimateResource;
|
||||
use App\Http\Resources\InvoiceResource;
|
||||
use App\Jobs\GenerateInvoicePdfJob;
|
||||
use App\Models\Estimate;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Mail\Markdown;
|
||||
|
||||
class InvoicesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvoiceService $invoiceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Invoice::class);
|
||||
|
||||
$limit = $request->input('limit', 10);
|
||||
|
||||
$invoices = Invoice::whereCompany()
|
||||
->applyFilters($request->all())
|
||||
->with('customer')
|
||||
->latest()
|
||||
->paginateData($limit);
|
||||
|
||||
return InvoiceResource::collection($invoices)
|
||||
->additional(['meta' => [
|
||||
'invoice_total_count' => Invoice::whereCompany()->count(),
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(Requests\InvoicesRequest $request)
|
||||
{
|
||||
$this->authorize('create', Invoice::class);
|
||||
|
||||
$invoice = $this->invoiceService->create($request);
|
||||
|
||||
if ($request->has('invoiceSend')) {
|
||||
$this->invoiceService->send($invoice, $request->only(['subject', 'body']));
|
||||
}
|
||||
|
||||
GenerateInvoicePdfJob::dispatch($invoice);
|
||||
|
||||
return new InvoiceResource($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Request $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('view', $invoice);
|
||||
|
||||
return new InvoiceResource($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(Requests\InvoicesRequest $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('update', $invoice);
|
||||
|
||||
$invoice = $this->invoiceService->update($invoice, $request);
|
||||
|
||||
GenerateInvoicePdfJob::dispatch($invoice, true);
|
||||
|
||||
return new InvoiceResource($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete the specified resources in storage.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function delete(DeleteInvoiceRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple invoices');
|
||||
|
||||
$ids = Invoice::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
$this->invoiceService->delete($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(SendInvoiceRequest $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('send invoice', $invoice);
|
||||
|
||||
$this->invoiceService->send($invoice, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendPreview(SendInvoiceRequest $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('send invoice', $invoice);
|
||||
|
||||
$markdown = new Markdown(view(), config('mail.markdown'));
|
||||
|
||||
$data = $this->invoiceService->sendInvoiceData($invoice, $request->all());
|
||||
$data['url'] = $invoice->invoicePdfUrl;
|
||||
|
||||
return $markdown->render('emails.send.invoice', ['data' => $data]);
|
||||
}
|
||||
|
||||
public function clone(Request $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('view', $invoice);
|
||||
$this->authorize('create', Invoice::class);
|
||||
|
||||
$newInvoice = $this->invoiceService->clone($invoice);
|
||||
|
||||
return new InvoiceResource($newInvoice);
|
||||
}
|
||||
|
||||
public function convertToEstimate(Request $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('create', Estimate::class);
|
||||
|
||||
$estimate = $this->invoiceService->convertToEstimate($invoice);
|
||||
|
||||
return new EstimateResource($estimate);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, Invoice $invoice)
|
||||
{
|
||||
$this->authorize('send invoice', $invoice);
|
||||
|
||||
$this->invoiceService->changeStatus($invoice, $request->status);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Item;
|
||||
namespace App\Http\Controllers\Company\Item;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests;
|
||||
@@ -8,14 +8,20 @@ use App\Http\Requests\DeleteItemsRequest;
|
||||
use App\Http\Resources\ItemResource;
|
||||
use App\Models\Item;
|
||||
use App\Models\TaxType;
|
||||
use App\Services\ItemService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ItemsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ItemService $itemService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve a list of existing Items.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -41,13 +47,13 @@ class ItemsController extends Controller
|
||||
* Create Item.
|
||||
*
|
||||
* @param App\Http\Requests\ItemsRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(Requests\ItemsRequest $request)
|
||||
{
|
||||
$this->authorize('create', Item::class);
|
||||
|
||||
$item = Item::createItem($request);
|
||||
$item = $this->itemService->create($request);
|
||||
|
||||
return new ItemResource($item);
|
||||
}
|
||||
@@ -55,7 +61,7 @@ class ItemsController extends Controller
|
||||
/**
|
||||
* get an existing Item.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(Item $item)
|
||||
{
|
||||
@@ -68,13 +74,13 @@ class ItemsController extends Controller
|
||||
* Update an existing Item.
|
||||
*
|
||||
* @param App\Http\Requests\ItemsRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(Requests\ItemsRequest $request, Item $item)
|
||||
{
|
||||
$this->authorize('update', $item);
|
||||
|
||||
$item = $item->updateItem($request);
|
||||
$item = $this->itemService->update($item, $request);
|
||||
|
||||
return new ItemResource($item);
|
||||
}
|
||||
@@ -82,14 +88,18 @@ class ItemsController extends Controller
|
||||
/**
|
||||
* Delete a list of existing Items.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function delete(DeleteItemsRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple items');
|
||||
|
||||
Item::destroy($request->ids);
|
||||
$ids = Item::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
Item::destroy($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -1,19 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Item;
|
||||
namespace App\Http\Controllers\Company\Item;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UnitRequest;
|
||||
use App\Http\Resources\UnitResource;
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class UnitsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -32,8 +33,8 @@ class UnitsController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(UnitRequest $request)
|
||||
{
|
||||
@@ -47,7 +48,7 @@ class UnitsController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(Unit $unit)
|
||||
{
|
||||
@@ -59,8 +60,8 @@ class UnitsController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function update(UnitRequest $request, Unit $unit)
|
||||
{
|
||||
@@ -74,7 +75,7 @@ class UnitsController extends Controller
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(Unit $unit)
|
||||
{
|
||||
@@ -1,20 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Users;
|
||||
namespace App\Http\Controllers\Company\Members;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeleteUserRequest;
|
||||
use App\Http\Requests\UserRequest;
|
||||
use App\Http\Requests\DeleteMemberRequest;
|
||||
use App\Http\Requests\MemberRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\MemberService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UsersController extends Controller
|
||||
class MembersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MemberService $memberService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -24,28 +30,28 @@ class UsersController extends Controller
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$users = User::applyFilters($request->all())
|
||||
$users = User::whereCompany()
|
||||
->applyFilters($request->all())
|
||||
->where('id', '<>', $user->id)
|
||||
->latest()
|
||||
->paginate($limit);
|
||||
|
||||
return UserResource::collection($users)
|
||||
->additional(['meta' => [
|
||||
'user_total_count' => User::count(),
|
||||
'user_total_count' => User::whereCompany()->count(),
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\UserRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(UserRequest $request)
|
||||
public function store(MemberRequest $request)
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$user = User::createFromRequest($request);
|
||||
$user = $this->memberService->create($request);
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
@@ -53,7 +59,7 @@ class UsersController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(User $user)
|
||||
{
|
||||
@@ -65,14 +71,13 @@ class UsersController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\UserRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(UserRequest $request, User $user)
|
||||
public function update(MemberRequest $request, User $user)
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$user->updateFromRequest($request);
|
||||
$this->memberService->update($user, $request);
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
@@ -80,15 +85,15 @@ class UsersController extends Controller
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function delete(DeleteUserRequest $request)
|
||||
public function delete(DeleteMemberRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple users', User::class);
|
||||
|
||||
if ($request->users) {
|
||||
User::deleteUsers($request->users);
|
||||
$this->memberService->delete($request->users);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Module;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
|
||||
/**
|
||||
* Read-only company-context Active Modules index.
|
||||
*
|
||||
* Lists every module the super admin has activated on this instance
|
||||
* (Module::enabled = true) and reports whether each one has registered a
|
||||
* settings schema. The frontend uses this to render the company-context
|
||||
* "Modules" landing page with a Settings button per active module.
|
||||
*
|
||||
* Activation is instance-global; per-company customization happens through
|
||||
* settings (per CompanySetting under the module.{slug}.* prefix).
|
||||
*
|
||||
* Slug convention: nwidart stores the module's PascalCase class name in
|
||||
* `modules.name` (e.g. "HelloWorld"), but URLs and registry keys use the
|
||||
* kebab-case form ("hello-world") for readability. We normalize via
|
||||
* Str::kebab() so module authors can call Registry::registerMenu('hello-world')
|
||||
* naturally without thinking about the storage format.
|
||||
*/
|
||||
class CompanyModulesController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$modules = Module::query()
|
||||
->where('enabled', true)
|
||||
->get()
|
||||
->map(function (Module $module) {
|
||||
$slug = Str::kebab($module->name);
|
||||
$menu = ModuleRegistry::menuFor($slug);
|
||||
$translatedMenuTitle = $this->translateMenuTitle($menu['title'] ?? null);
|
||||
$displayName = $translatedMenuTitle ?? Str::headline($module->name);
|
||||
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'name' => $module->name,
|
||||
'display_name' => $displayName,
|
||||
'version' => $module->version,
|
||||
'has_settings' => ModuleRegistry::settingsFor($slug) !== null,
|
||||
'menu' => $menu === null
|
||||
? null
|
||||
: [
|
||||
...$menu,
|
||||
'title' => $translatedMenuTitle ?? $menu['title'],
|
||||
],
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json(['data' => $modules]);
|
||||
}
|
||||
|
||||
private function translateMenuTitle(?string $title): ?string
|
||||
{
|
||||
if ($title === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$translatedTitle = __($title);
|
||||
|
||||
if (! is_string($translatedTitle) || $translatedTitle === $title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $translatedTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Modules;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CompanySetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
use InvoiceShelf\Modules\Settings\Schema;
|
||||
|
||||
/**
|
||||
* Schema-driven module settings backend.
|
||||
*
|
||||
* Each active module's ServiceProvider::boot() calls
|
||||
* Registry::registerSettings($slug, $schema) once at app boot. This controller
|
||||
* exposes that schema to the frontend, validates submitted values against the
|
||||
* schema's per-field rules, and persists per-company values into CompanySetting
|
||||
* under the key prefix `module.{slug}.{field_key}`.
|
||||
*
|
||||
* Activation is instance-global, but settings are per-company — two companies
|
||||
* on the same instance can configure the same activated module differently.
|
||||
*/
|
||||
class ModuleSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$schema = ModuleRegistry::settingsFor($slug);
|
||||
|
||||
if ($schema === null) {
|
||||
abort(404, "Module '{$slug}' has not registered a settings schema.");
|
||||
}
|
||||
|
||||
$values = collect($schema->fields())
|
||||
->mapWithKeys(fn (array $field) => [
|
||||
$field['key'] => CompanySetting::getSetting(
|
||||
"module.{$slug}.{$field['key']}",
|
||||
$request->header('company')
|
||||
) ?? $field['default'],
|
||||
])
|
||||
->all();
|
||||
|
||||
return response()->json([
|
||||
'schema' => $this->translateSchema($schema->toArray()),
|
||||
'values' => $values,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$this->authorize('manage modules');
|
||||
|
||||
$schema = ModuleRegistry::settingsFor($slug);
|
||||
|
||||
if ($schema === null) {
|
||||
abort(404, "Module '{$slug}' has not registered a settings schema.");
|
||||
}
|
||||
|
||||
$rules = $this->buildRules($schema);
|
||||
$allowedKeys = array_keys($rules);
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
$companyId = $request->header('company');
|
||||
|
||||
// Only persist keys the schema knows about — silently drop unknown keys
|
||||
// rather than letting modules write arbitrary settings.
|
||||
$settingsToWrite = [];
|
||||
foreach ($allowedKeys as $key) {
|
||||
if (array_key_exists($key, $validated)) {
|
||||
$settingsToWrite["module.{$slug}.{$key}"] = $this->normalizeForStorage($validated[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($settingsToWrite !== []) {
|
||||
CompanySetting::setSettings($settingsToWrite, $companyId);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Schema's field rule arrays into a flat Laravel validator rules array.
|
||||
*
|
||||
* Field rules are passed through verbatim — a field declared as
|
||||
* `'rules' => ['required', 'string', 'max:255']` becomes
|
||||
* `['my_field' => ['required', 'string', 'max:255']]`. The frontend's
|
||||
* BaseSchemaForm.vue understands a subset of these for client-side validation;
|
||||
* the backend validator is the source of truth.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function buildRules(Schema $schema): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach ($schema->fields() as $field) {
|
||||
$rules[$field['key']] = $this->withTypeRule($field);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a sensible per-type validation rule so booleans must be booleans,
|
||||
* numbers must be numeric, etc., even if the module didn't declare it.
|
||||
*
|
||||
* @param array<string, mixed> $field
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function withTypeRule(array $field): array
|
||||
{
|
||||
/** @var array<int, string> $declared */
|
||||
$declared = $field['rules'] ?? [];
|
||||
|
||||
$typeRule = match ($field['type']) {
|
||||
'switch' => 'boolean',
|
||||
'number' => 'numeric',
|
||||
'multiselect' => 'array',
|
||||
default => 'nullable',
|
||||
};
|
||||
|
||||
// Avoid duplicating the type rule if the module already declared it
|
||||
if (in_array($typeRule, $declared, true)) {
|
||||
return $declared;
|
||||
}
|
||||
|
||||
return array_merge([$typeRule], $declared);
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanySetting stores everything as strings. Cast booleans, ints, and
|
||||
* arrays to a representation that round-trips through getSetting/setSetting
|
||||
* without losing information. Reads happen in show() above and naturally
|
||||
* return strings; the frontend handles re-coercion in BaseSchemaForm.vue.
|
||||
*/
|
||||
/**
|
||||
* Translate section titles and field labels in the schema so the
|
||||
* frontend receives ready-to-display strings instead of Laravel
|
||||
* translation keys it cannot resolve (e.g. `helloworld::settings.greeting`).
|
||||
*
|
||||
* @param array{sections: list<array<string, mixed>>} $schema
|
||||
* @return array{sections: list<array<string, mixed>>}
|
||||
*/
|
||||
private function translateSchema(array $schema): array
|
||||
{
|
||||
foreach ($schema['sections'] as &$section) {
|
||||
if (isset($section['title'])) {
|
||||
$section['title'] = __($section['title']);
|
||||
}
|
||||
|
||||
foreach ($section['fields'] as &$field) {
|
||||
if (isset($field['label'])) {
|
||||
$field['label'] = __($field['label']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function normalizeForStorage(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Payment;
|
||||
namespace App\Http\Controllers\Company\Payment;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaymentMethodRequest;
|
||||
use App\Http\Resources\PaymentMethodResource;
|
||||
use App\Models\PaymentMethod;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PaymentMethodsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -33,8 +34,8 @@ class PaymentMethodsController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(PaymentMethodRequest $request)
|
||||
{
|
||||
@@ -48,7 +49,7 @@ class PaymentMethodsController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(PaymentMethod $paymentMethod)
|
||||
{
|
||||
@@ -60,8 +61,8 @@ class PaymentMethodsController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function update(PaymentMethodRequest $request, PaymentMethod $paymentMethod)
|
||||
{
|
||||
@@ -75,7 +76,7 @@ class PaymentMethodsController extends Controller
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function destroy(PaymentMethod $paymentMethod)
|
||||
{
|
||||
@@ -1,20 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Payment;
|
||||
namespace App\Http\Controllers\Company\Payment;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeletePaymentsRequest;
|
||||
use App\Http\Requests\PaymentRequest;
|
||||
use App\Http\Requests\SendPaymentRequest;
|
||||
use App\Http\Resources\PaymentResource;
|
||||
use App\Models\Payment;
|
||||
use App\Services\PaymentService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Mail\Markdown;
|
||||
|
||||
class PaymentsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -40,14 +48,14 @@ class PaymentsController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(PaymentRequest $request)
|
||||
{
|
||||
$this->authorize('create', Payment::class);
|
||||
|
||||
$payment = Payment::createPayment($request);
|
||||
$payment = $this->paymentService->create($request);
|
||||
|
||||
return new PaymentResource($payment);
|
||||
}
|
||||
@@ -63,7 +71,7 @@ class PaymentsController extends Controller
|
||||
{
|
||||
$this->authorize('update', $payment);
|
||||
|
||||
$payment = $payment->updatePayment($request);
|
||||
$payment = $this->paymentService->update($payment, $request);
|
||||
|
||||
return new PaymentResource($payment);
|
||||
}
|
||||
@@ -72,10 +80,35 @@ class PaymentsController extends Controller
|
||||
{
|
||||
$this->authorize('delete multiple payments');
|
||||
|
||||
Payment::deletePayments($request->ids);
|
||||
$ids = Payment::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
$this->paymentService->delete($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(SendPaymentRequest $request, Payment $payment)
|
||||
{
|
||||
$this->authorize('send payment', $payment);
|
||||
|
||||
$response = $this->paymentService->send($payment, $request->all());
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
public function sendPreview(Request $request, Payment $payment)
|
||||
{
|
||||
$this->authorize('send payment', $payment);
|
||||
|
||||
$markdown = new Markdown(view(), config('mail.markdown'));
|
||||
|
||||
$data = $this->paymentService->sendPaymentData($payment, $request->all());
|
||||
$data['url'] = $payment->paymentPdfUrl;
|
||||
|
||||
return $markdown->render('emails.send.payment', ['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\RecurringInvoice;
|
||||
namespace App\Http\Controllers\Company\RecurringInvoice;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\RecurringInvoiceRequest;
|
||||
use App\Http\Resources\RecurringInvoiceResource;
|
||||
use App\Models\RecurringInvoice;
|
||||
use App\Services\RecurringInvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class RecurringInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecurringInvoiceService $recurringInvoiceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -34,14 +40,14 @@ class RecurringInvoiceController extends Controller
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function store(RecurringInvoiceRequest $request)
|
||||
{
|
||||
$this->authorize('create', RecurringInvoice::class);
|
||||
|
||||
$recurringInvoice = RecurringInvoice::createFromRequest($request);
|
||||
$recurringInvoice = $this->recurringInvoiceService->create($request);
|
||||
|
||||
return new RecurringInvoiceResource($recurringInvoice);
|
||||
}
|
||||
@@ -49,7 +55,7 @@ class RecurringInvoiceController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function show(RecurringInvoice $recurringInvoice)
|
||||
{
|
||||
@@ -61,14 +67,14 @@ class RecurringInvoiceController extends Controller
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function update(RecurringInvoiceRequest $request, RecurringInvoice $recurringInvoice)
|
||||
{
|
||||
$this->authorize('update', $recurringInvoice);
|
||||
|
||||
$recurringInvoice->updateFromRequest($request);
|
||||
$this->recurringInvoiceService->update($recurringInvoice, $request);
|
||||
|
||||
return new RecurringInvoiceResource($recurringInvoice);
|
||||
}
|
||||
@@ -76,14 +82,18 @@ class RecurringInvoiceController extends Controller
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param \App\Models\RecurringInvoice $recurringInvoice
|
||||
* @return \Illuminate\Http\Response
|
||||
* @param RecurringInvoice $recurringInvoice
|
||||
* @return Response
|
||||
*/
|
||||
public function delete(Request $request)
|
||||
{
|
||||
$this->authorize('delete multiple recurring invoices');
|
||||
|
||||
RecurringInvoice::deleteRecurringInvoice($request->ids);
|
||||
$ids = RecurringInvoice::whereCompany()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
$this->recurringInvoiceService->delete($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\RecurringInvoice;
|
||||
namespace App\Http\Controllers\Company\RecurringInvoice;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RecurringInvoice;
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Report;
|
||||
namespace App\Http\Controllers\Company\Report;
|
||||
|
||||
use App\Facades\Pdf;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Customer;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use PDF;
|
||||
|
||||
class CustomerSalesReportController extends Controller
|
||||
{
|
||||
@@ -18,7 +19,7 @@ class CustomerSalesReportController extends Controller
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param string $hash
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request, $hash)
|
||||
{
|
||||
@@ -84,7 +85,7 @@ class CustomerSalesReportController extends Controller
|
||||
'currency' => $currency,
|
||||
]);
|
||||
|
||||
$pdf = PDF::loadView('app.pdf.reports.sales-customers');
|
||||
$pdf = Pdf::loadView('app.pdf.reports.sales-customers');
|
||||
|
||||
if ($request->has('preview')) {
|
||||
return view('app.pdf.reports.sales-customers');
|
||||
@@ -1,16 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Report;
|
||||
namespace App\Http\Controllers\Company\Report;
|
||||
|
||||
use App\Facades\Pdf;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Expense;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use PDF;
|
||||
|
||||
class ExpensesReportController extends Controller
|
||||
{
|
||||
@@ -18,7 +20,7 @@ class ExpensesReportController extends Controller
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param string $hash
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return View|Response
|
||||
*/
|
||||
public function __invoke(Request $request, $hash)
|
||||
{
|
||||
@@ -30,14 +32,26 @@ class ExpensesReportController extends Controller
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
$expenseCategories = Expense::with('category')
|
||||
// Fetch individual expenses (filtered and ordered by date), then group by category
|
||||
$expenses = Expense::with('category')
|
||||
->whereCompanyId($company->id)
|
||||
->applyFilters($request->only(['from_date', 'to_date']))
|
||||
->expensesAttributes()
|
||||
->applyFilters($request->only(['from_date', 'to_date', 'expense_category_id']))
|
||||
->orderBy('expense_date', 'asc')
|
||||
->get();
|
||||
$totalAmount = 0;
|
||||
foreach ($expenseCategories as $category) {
|
||||
$totalAmount += $category->total_amount;
|
||||
|
||||
$totalAmount = $expenses->sum('base_amount');
|
||||
|
||||
$grouped = $expenses->groupBy(function ($item) {
|
||||
return $item->category ? $item->category->name : trans('expenses.uncategorized');
|
||||
});
|
||||
|
||||
$expenseGroups = collect();
|
||||
foreach ($grouped as $categoryName => $group) {
|
||||
$expenseGroups->push([
|
||||
'name' => $categoryName,
|
||||
'expenses' => $group,
|
||||
'total' => $group->sum('base_amount'),
|
||||
]);
|
||||
}
|
||||
|
||||
$dateFormat = CompanySetting::getSetting('carbon_date_format', $company->id);
|
||||
@@ -61,7 +75,7 @@ class ExpensesReportController extends Controller
|
||||
->get();
|
||||
|
||||
view()->share([
|
||||
'expenseCategories' => $expenseCategories,
|
||||
'expenseGroups' => $expenseGroups,
|
||||
'colorSettings' => $colorSettings,
|
||||
'totalExpense' => $totalAmount,
|
||||
'company' => $company,
|
||||
@@ -69,7 +83,7 @@ class ExpensesReportController extends Controller
|
||||
'to_date' => $to_date,
|
||||
'currency' => $currency,
|
||||
]);
|
||||
$pdf = PDF::loadView('app.pdf.reports.expenses');
|
||||
$pdf = Pdf::loadView('app.pdf.reports.expenses');
|
||||
|
||||
if ($request->has('preview')) {
|
||||
return view('app.pdf.reports.expenses');
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Report;
|
||||
namespace App\Http\Controllers\Company\Report;
|
||||
|
||||
use App\Facades\Pdf;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\InvoiceItem;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use PDF;
|
||||
|
||||
class ItemSalesReportController extends Controller
|
||||
{
|
||||
@@ -18,7 +19,7 @@ class ItemSalesReportController extends Controller
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param string $hash
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request, $hash)
|
||||
{
|
||||
@@ -69,7 +70,7 @@ class ItemSalesReportController extends Controller
|
||||
'to_date' => $to_date,
|
||||
'currency' => $currency,
|
||||
]);
|
||||
$pdf = PDF::loadView('app.pdf.reports.sales-items');
|
||||
$pdf = Pdf::loadView('app.pdf.reports.sales-items');
|
||||
|
||||
if ($request->has('preview')) {
|
||||
return view('app.pdf.reports.sales-items');
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Report;
|
||||
namespace App\Http\Controllers\Company\Report;
|
||||
|
||||
use App\Facades\Pdf;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanySetting;
|
||||
@@ -9,9 +10,9 @@ use App\Models\Currency;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Payment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use PDF;
|
||||
|
||||
class ProfitLossReportController extends Controller
|
||||
{
|
||||
@@ -19,7 +20,7 @@ class ProfitLossReportController extends Controller
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param string $hash
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request, $hash)
|
||||
{
|
||||
@@ -77,7 +78,7 @@ class ProfitLossReportController extends Controller
|
||||
'to_date' => $to_date,
|
||||
'currency' => $currency,
|
||||
]);
|
||||
$pdf = PDF::loadView('app.pdf.reports.profit-loss');
|
||||
$pdf = Pdf::loadView('app.pdf.reports.profit-loss');
|
||||
|
||||
if ($request->has('preview')) {
|
||||
return view('app.pdf.reports.profit-loss');
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Report;
|
||||
namespace App\Http\Controllers\Company\Report;
|
||||
|
||||
use App\Facades\Pdf;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Tax;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use PDF;
|
||||
|
||||
class TaxSummaryReportController extends Controller
|
||||
{
|
||||
@@ -18,7 +19,7 @@ class TaxSummaryReportController extends Controller
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param string $hash
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request, $hash)
|
||||
{
|
||||
@@ -72,7 +73,7 @@ class TaxSummaryReportController extends Controller
|
||||
'currency' => $currency,
|
||||
]);
|
||||
|
||||
$pdf = PDF::loadView('app.pdf.reports.tax-summary');
|
||||
$pdf = Pdf::loadView('app.pdf.reports.tax-summary');
|
||||
|
||||
if ($request->has('preview')) {
|
||||
return view('app.pdf.reports.tax-summary');
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Admin\Role;
|
||||
namespace App\Http\Controllers\Company\Role;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AbilitiesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user