Compare commits
30 Commits
fa1d6a4295
...
mise-en-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc93f3fa9a | ||
|
|
6d320bedc9 | ||
|
|
cc7abba373 | ||
|
|
890449d5e3 | ||
|
|
506225ac62 | ||
|
|
bc6d7d4542 | ||
|
|
5b9ca568f8 | ||
|
|
7cbb48e679 | ||
|
|
8cd4854924 | ||
|
|
a7e5f91a21 | ||
|
|
a182f1b922 | ||
|
|
b79791ff7a | ||
|
|
7e111ec041 | ||
|
|
4e7af9119a | ||
|
|
1ea5cea6fc | ||
|
|
06f394b728 | ||
|
|
67b85d323c | ||
|
|
beaabceda4 | ||
|
|
60d0e1c6c4 | ||
|
|
b30ae0f10a | ||
|
|
fb3f41df4d | ||
|
|
4e4573f57b | ||
|
|
4545bdba81 | ||
|
|
272b4bc9c9 | ||
|
|
0f7a886cf7 | ||
|
|
2bcd1ca4c3 | ||
|
|
f38d75362c | ||
|
|
13a890606d | ||
|
|
fb6a271f66 | ||
|
|
25d395b41a |
49
em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache
Normal file
49
em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
||||||
|
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
||||||
|
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
||||||
|
favicon.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||||
|
icons/Icon-maskable-512.png,1766235851206,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
|
||||||
|
icons/Icon-maskable-192.png,1766235851135,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
|
||||||
|
icons/Icon-512.png,1766235851087,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
|
||||||
|
icons/Icon-192.png,1766235851013,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
|
||||||
|
canvaskit/skwasm_heavy.wasm,1759914809247,509ac05ee7c60aaee61d52bad4527f40e1ce79511ca29908237472a1cd476180
|
||||||
|
canvaskit/skwasm_heavy.js.symbols,1759914809219,612ffa6a568de0500758c132cd0ea7d7c4f389157d618fe2b4255e73f3068e8f
|
||||||
|
canvaskit/skwasm_heavy.js,1759914809214,5552644d0313045f87d52097dd1e86a75f64b9e048a450ce2c885e313ed1b4c5
|
||||||
|
canvaskit/skwasm.wasm,1759914809212,85c6ff573c3f76f2d84f5553fab09bf0d0f715519c679f7626722ac0fb501640
|
||||||
|
canvaskit/skwasm.js.symbols,1759914809190,83718024df2bd4902e4c0fdfa47ea7e9ca401dcf7f31f4061c6da8478f12987f
|
||||||
|
canvaskit/skwasm.js,1759914809185,2e251855d712f083d8c6aa79bf49f6d2a8e15311f161115eb8a39bcf0688c878
|
||||||
|
canvaskit/canvaskit.wasm,1759914809134,52dedf2cd2d6bf150262bf145ffde2fc80e296d98a9d3764961eb6f84c8ce988
|
||||||
|
canvaskit/canvaskit.js.symbols,1759914809092,a3577bf24071e07f599ac61535dbee4ae4d37c5cc6ee6289379576773f9c336b
|
||||||
|
canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074dd2afc181f26b323d2dd1
|
||||||
|
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
|
||||||
|
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
|
||||||
|
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
|
||||||
|
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
|
||||||
|
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
||||||
|
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
||||||
|
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||||
|
assets/assets/sounds/ok.mp3,1771938119844,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
|
||||||
|
assets/assets/sounds/error.mp3,1771938125144,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
|
||||||
|
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
||||||
|
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
||||||
|
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
||||||
|
assets/assets/logos/RectangleLogoBlack.png,1760462340000,536ebd370e55736b3622a673c684a150e23f5d3b82c71283d7a3f4a93564c02c
|
||||||
|
assets/assets/logos/LowQRectangleLogoBlack.png,1761139425319,ae4f8e428dd3634a14b45421a3c9b30fea8592ff33ff21f6962ed548e7db242b
|
||||||
|
assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb6399444016c67afe9e223fddf4ecdac1dad822198
|
||||||
|
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||||
|
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||||
|
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||||
|
version.json,1772532792027,2b3f91e827bc27a1901342a048b1bd81d0aabc50935717f9851e1a3ad6cb7411
|
||||||
|
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae
|
||||||
|
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
|
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9
|
||||||
|
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad
|
||||||
|
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
|
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
|
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
|
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
|
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
|
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093
|
||||||
|
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
|
||||||
|
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f
|
||||||
450
em2rp/.github/agents/Dart and flutter.agent.md
vendored
Normal file
450
em2rp/.github/agents/Dart and flutter.agent.md
vendored
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
---
|
||||||
|
description: 'Instructions for writing Dart and Flutter code following the official recommendations.'
|
||||||
|
applyTo: '**/*.dart'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dart and Flutter
|
||||||
|
|
||||||
|
Best practices recommended by the Dart and Flutter teams. These instructions were taken from [Effective Dart](https://dart.dev/effective-dart) and [Architecture Recommendations](https://docs.flutter.dev/app-architecture/recommendations).
|
||||||
|
|
||||||
|
## Effective Dart
|
||||||
|
|
||||||
|
Over the past several years, we've written a ton of Dart code and learned a lot about what works well and what doesn't. We're sharing this with you so you can write consistent, robust, fast code too. There are two overarching themes:
|
||||||
|
|
||||||
|
1. **Be consistent.** When it comes to things like formatting, and casing, arguments about which is better are subjective and impossible to resolve. What we do know is that being *consistent* is objectively helpful.
|
||||||
|
|
||||||
|
If two pieces of code look different it should be because they *are* different in some meaningful way. When a bit of code stands out and catches your eye, it should do so for a useful reason.
|
||||||
|
|
||||||
|
2. **Be brief.** Dart was designed to be familiar, so it inherits many of the same statements and expressions as C, Java, JavaScript and other languages. But we created Dart because there is a lot of room to improve on what those languages offer. We added a bunch of features, from string interpolation to initializing formals, to help you express your intent more simply and easily.
|
||||||
|
|
||||||
|
If there are multiple ways to say something, you should generally pick the most concise one. This is not to say you should `code golf` yourself into cramming a whole program into a single line. The goal is code that is *economical*, not *dense*.
|
||||||
|
|
||||||
|
### The topics
|
||||||
|
|
||||||
|
We split the guidelines into a few separate topics for easy digestion:
|
||||||
|
|
||||||
|
* **Style** – This defines the rules for laying out and organizing code, or at least the parts that `dart format` doesn't handle for you. The style topic also specifies how identifiers are formatted: `camelCase`, `using_underscores`, etc.
|
||||||
|
|
||||||
|
* **Documentation** – This tells you everything you need to know about what goes inside comments. Both doc comments and regular, run-of-the-mill code comments.
|
||||||
|
|
||||||
|
* **Usage** – This teaches you how to make the best use of language features to implement behavior. If it's in a statement or expression, it's covered here.
|
||||||
|
|
||||||
|
* **Design** – This is the softest topic, but the one with the widest scope. It covers what we've learned about designing consistent, usable APIs for libraries. If it's in a type signature or declaration, this goes over it.
|
||||||
|
|
||||||
|
### How to read the topics
|
||||||
|
|
||||||
|
Each topic is broken into a few sections. Sections contain a list of guidelines. Each guideline starts with one of these words:
|
||||||
|
|
||||||
|
* **DO** guidelines describe practices that should always be followed. There will almost never be a valid reason to stray from them.
|
||||||
|
|
||||||
|
* **DON'T** guidelines are the converse: things that are almost never a good idea. Hopefully, we don't have as many of these as other languages do because we have less historical baggage.
|
||||||
|
|
||||||
|
* **PREFER** guidelines are practices that you *should* follow. However, there may be circumstances where it makes sense to do otherwise. Just make sure you understand the full implications of ignoring the guideline when you do.
|
||||||
|
|
||||||
|
* **AVOID** guidelines are the dual to "prefer": stuff you shouldn't do but where there may be good reasons to on rare occasions.
|
||||||
|
|
||||||
|
* **CONSIDER** guidelines are practices that you might or might not want to follow, depending on circumstances, precedents, and your own preference.
|
||||||
|
|
||||||
|
Some guidelines describe an **exception** where the rule does *not* apply. When listed, the exceptions may not be exhaustive—you might still need to use your judgement on other cases.
|
||||||
|
|
||||||
|
This sounds like the police are going to beat down your door if you don't have your laces tied correctly. Things aren't that bad. Most of the guidelines here are common sense and we're all reasonable people. The goal, as always, is nice, readable and maintainable code.
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
#### Style
|
||||||
|
|
||||||
|
##### Identifiers
|
||||||
|
|
||||||
|
* DO name types using `UpperCamelCase`.
|
||||||
|
* DO name extensions using `UpperCamelCase`.
|
||||||
|
* DO name packages, directories, and source files using `lowercase_with_underscores`.
|
||||||
|
* DO name import prefixes using `lowercase_with_underscores`.
|
||||||
|
* DO name other identifiers using `lowerCamelCase`.
|
||||||
|
* PREFER using `lowerCamelCase` for constant names.
|
||||||
|
* DO capitalize acronyms and abbreviations longer than two letters like words.
|
||||||
|
* PREFER using wildcards for unused callback parameters.
|
||||||
|
* DON'T use a leading underscore for identifiers that aren't private.
|
||||||
|
* DON'T use prefix letters.
|
||||||
|
* DON'T explicitly name libraries.
|
||||||
|
|
||||||
|
##### Ordering
|
||||||
|
|
||||||
|
* DO place `dart:` imports before other imports.
|
||||||
|
* DO place `package:` imports before relative imports.
|
||||||
|
* DO specify exports in a separate section after all imports.
|
||||||
|
* DO sort sections alphabetically.
|
||||||
|
|
||||||
|
##### Formatting
|
||||||
|
|
||||||
|
* DO format your code using `dart format`.
|
||||||
|
* CONSIDER changing your code to make it more formatter-friendly.
|
||||||
|
* PREFER lines 80 characters or fewer.
|
||||||
|
* DO use curly braces for all flow control statements.
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
##### Comments
|
||||||
|
|
||||||
|
* DO format comments like sentences.
|
||||||
|
* DON'T use block comments for documentation.
|
||||||
|
|
||||||
|
##### Doc comments
|
||||||
|
|
||||||
|
* DO use `///` doc comments to document members and types.
|
||||||
|
* PREFER writing doc comments for public APIs.
|
||||||
|
* CONSIDER writing a library-level doc comment.
|
||||||
|
* CONSIDER writing doc comments for private APIs.
|
||||||
|
* DO start doc comments with a single-sentence summary.
|
||||||
|
* DO separate the first sentence of a doc comment into its own paragraph.
|
||||||
|
* AVOID redundancy with the surrounding context.
|
||||||
|
* PREFER starting comments of a function or method with third-person verbs if its main purpose is a side effect.
|
||||||
|
* PREFER starting a non-boolean variable or property comment with a noun phrase.
|
||||||
|
* PREFER starting a boolean variable or property comment with "Whether" followed by a noun or gerund phrase.
|
||||||
|
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
|
||||||
|
* DON'T write documentation for both the getter and setter of a property.
|
||||||
|
* PREFER starting library or type comments with noun phrases.
|
||||||
|
* CONSIDER including code samples in doc comments.
|
||||||
|
* DO use square brackets in doc comments to refer to in-scope identifiers.
|
||||||
|
* DO use prose to explain parameters, return values, and exceptions.
|
||||||
|
* DO put doc comments before metadata annotations.
|
||||||
|
|
||||||
|
##### Markdown
|
||||||
|
|
||||||
|
* AVOID using markdown excessively.
|
||||||
|
* AVOID using HTML for formatting.
|
||||||
|
* PREFER backtick fences for code blocks.
|
||||||
|
|
||||||
|
##### Writing
|
||||||
|
|
||||||
|
* PREFER brevity.
|
||||||
|
* AVOID abbreviations and acronyms unless they are obvious.
|
||||||
|
* PREFER using "this" instead of "the" to refer to a member's instance.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
##### Libraries
|
||||||
|
|
||||||
|
* DO use strings in `part of` directives.
|
||||||
|
* DON'T import libraries that are inside the `src` directory of another package.
|
||||||
|
* DON'T allow an import path to reach into or out of `lib`.
|
||||||
|
* PREFER relative import paths.
|
||||||
|
|
||||||
|
##### Null
|
||||||
|
|
||||||
|
* DON'T explicitly initialize variables to `null`.
|
||||||
|
* DON'T use an explicit default value of `null`.
|
||||||
|
* DON'T use `true` or `false` in equality operations.
|
||||||
|
* AVOID `late` variables if you need to check whether they are initialized.
|
||||||
|
* CONSIDER type promotion or null-check patterns for using nullable types.
|
||||||
|
|
||||||
|
##### Strings
|
||||||
|
|
||||||
|
* DO use adjacent strings to concatenate string literals.
|
||||||
|
* PREFER using interpolation to compose strings and values.
|
||||||
|
* AVOID using curly braces in interpolation when not needed.
|
||||||
|
|
||||||
|
##### Collections
|
||||||
|
|
||||||
|
* DO use collection literals when possible.
|
||||||
|
* DON'T use `.length` to see if a collection is empty.
|
||||||
|
* AVOID using `Iterable.forEach()` with a function literal.
|
||||||
|
* DON'T use `List.from()` unless you intend to change the type of the result.
|
||||||
|
* DO use `whereType()` to filter a collection by type.
|
||||||
|
* DON'T use `cast()` when a nearby operation will do.
|
||||||
|
* AVOID using `cast()`.
|
||||||
|
|
||||||
|
##### Functions
|
||||||
|
|
||||||
|
* DO use a function declaration to bind a function to a name.
|
||||||
|
* DON'T create a lambda when a tear-off will do.
|
||||||
|
|
||||||
|
##### Variables
|
||||||
|
|
||||||
|
* DO follow a consistent rule for `var` and `final` on local variables.
|
||||||
|
* AVOID storing what you can calculate.
|
||||||
|
|
||||||
|
##### Members
|
||||||
|
|
||||||
|
* DON'T wrap a field in a getter and setter unnecessarily.
|
||||||
|
* PREFER using a `final` field to make a read-only property.
|
||||||
|
* CONSIDER using `=>` for simple members.
|
||||||
|
* DON'T use `this.` except to redirect to a named constructor or to avoid shadowing.
|
||||||
|
* DO initialize fields at their declaration when possible.
|
||||||
|
|
||||||
|
##### Constructors
|
||||||
|
|
||||||
|
* DO use initializing formals when possible.
|
||||||
|
* DON'T use `late` when a constructor initializer list will do.
|
||||||
|
* DO use `;` instead of `{}` for empty constructor bodies.
|
||||||
|
* DON'T use `new`.
|
||||||
|
* DON'T use `const` redundantly.
|
||||||
|
|
||||||
|
##### Error handling
|
||||||
|
|
||||||
|
* AVOID catches without `on` clauses.
|
||||||
|
* DON'T discard errors from catches without `on` clauses.
|
||||||
|
* DO throw objects that implement `Error` only for programmatic errors.
|
||||||
|
* DON'T explicitly catch `Error` or types that implement it.
|
||||||
|
* DO use `rethrow` to rethrow a caught exception.
|
||||||
|
|
||||||
|
##### Asynchrony
|
||||||
|
|
||||||
|
* PREFER async/await over using raw futures.
|
||||||
|
* DON'T use `async` when it has no useful effect.
|
||||||
|
* CONSIDER using higher-order methods to transform a stream.
|
||||||
|
* AVOID using Completer directly.
|
||||||
|
* DO test for `Future<T>` when disambiguating a `FutureOr<T>` whose type argument could be `Object`.
|
||||||
|
|
||||||
|
#### Design
|
||||||
|
|
||||||
|
##### Names
|
||||||
|
|
||||||
|
* DO use terms consistently.
|
||||||
|
* AVOID abbreviations.
|
||||||
|
* PREFER putting the most descriptive noun last.
|
||||||
|
* CONSIDER making the code read like a sentence.
|
||||||
|
* PREFER a noun phrase for a non-boolean property or variable.
|
||||||
|
* PREFER a non-imperative verb phrase for a boolean property or variable.
|
||||||
|
* CONSIDER omitting the verb for a named boolean parameter.
|
||||||
|
* PREFER the "positive" name for a boolean property or variable.
|
||||||
|
* PREFER an imperative verb phrase for a function or method whose main purpose is a side effect.
|
||||||
|
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
|
||||||
|
* CONSIDER an imperative verb phrase for a function or method if you want to draw attention to the work it performs.
|
||||||
|
* AVOID starting a method name with `get`.
|
||||||
|
* PREFER naming a method `to...()` if it copies the object's state to a new object.
|
||||||
|
* PREFER naming a method `as...()` if it returns a different representation backed by the original object.
|
||||||
|
* AVOID describing the parameters in the function's or method's name.
|
||||||
|
* DO follow existing mnemonic conventions when naming type parameters.
|
||||||
|
|
||||||
|
##### Libraries
|
||||||
|
|
||||||
|
* PREFER making declarations private.
|
||||||
|
* CONSIDER declaring multiple classes in the same library.
|
||||||
|
|
||||||
|
##### Classes and mixins
|
||||||
|
|
||||||
|
* AVOID defining a one-member abstract class when a simple function will do.
|
||||||
|
* AVOID defining a class that contains only static members.
|
||||||
|
* AVOID extending a class that isn't intended to be subclassed.
|
||||||
|
* DO use class modifiers to control if your class can be extended.
|
||||||
|
* AVOID implementing a class that isn't intended to be an interface.
|
||||||
|
* DO use class modifiers to control if your class can be an interface.
|
||||||
|
* PREFER defining a pure `mixin` or pure `class` to a `mixin class`.
|
||||||
|
|
||||||
|
##### Constructors
|
||||||
|
|
||||||
|
* CONSIDER making your constructor `const` if the class supports it.
|
||||||
|
|
||||||
|
##### Members
|
||||||
|
|
||||||
|
* PREFER making fields and top-level variables `final`.
|
||||||
|
* DO use getters for operations that conceptually access properties.
|
||||||
|
* DO use setters for operations that conceptually change properties.
|
||||||
|
* DON'T define a setter without a corresponding getter.
|
||||||
|
* AVOID using runtime type tests to fake overloading.
|
||||||
|
* AVOID public `late final` fields without initializers.
|
||||||
|
* AVOID returning nullable `Future`, `Stream`, and collection types.
|
||||||
|
* AVOID returning `this` from methods just to enable a fluent interface.
|
||||||
|
|
||||||
|
##### Types
|
||||||
|
|
||||||
|
* DO type annotate variables without initializers.
|
||||||
|
* DO type annotate fields and top-level variables if the type isn't obvious.
|
||||||
|
* DON'T redundantly type annotate initialized local variables.
|
||||||
|
* DO annotate return types on function declarations.
|
||||||
|
* DO annotate parameter types on function declarations.
|
||||||
|
* DON'T annotate inferred parameter types on function expressions.
|
||||||
|
* DON'T type annotate initializing formals.
|
||||||
|
* DO write type arguments on generic invocations that aren't inferred.
|
||||||
|
* DON'T write type arguments on generic invocations that are inferred.
|
||||||
|
* AVOID writing incomplete generic types.
|
||||||
|
* DO annotate with `dynamic` instead of letting inference fail.
|
||||||
|
* PREFER signatures in function type annotations.
|
||||||
|
* DON'T specify a return type for a setter.
|
||||||
|
* DON'T use the legacy typedef syntax.
|
||||||
|
* PREFER inline function types over typedefs.
|
||||||
|
* PREFER using function type syntax for parameters.
|
||||||
|
* AVOID using `dynamic` unless you want to disable static checking.
|
||||||
|
* DO use `Future<void>` as the return type of asynchronous members that do not produce values.
|
||||||
|
* AVOID using `FutureOr<T>` as a return type.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
* AVOID positional boolean parameters.
|
||||||
|
* AVOID optional positional parameters if the user may want to omit earlier parameters.
|
||||||
|
* AVOID mandatory parameters that accept a special "no argument" value.
|
||||||
|
* DO use inclusive start and exclusive end parameters to accept a range.
|
||||||
|
|
||||||
|
##### Equality
|
||||||
|
|
||||||
|
* DO override `hashCode` if you override `==`.
|
||||||
|
* DO make your `==` operator obey the mathematical rules of equality.
|
||||||
|
* AVOID defining custom equality for mutable classes.
|
||||||
|
* DON'T make the parameter to `==` nullable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flutter Architecture Recommendations
|
||||||
|
|
||||||
|
This page presents architecture best practices, why they matter, and
|
||||||
|
whether we recommend them for your Flutter application.
|
||||||
|
You should treat these recommendations as recommendations,
|
||||||
|
and not steadfast rules, and you should
|
||||||
|
adapt them to your app's unique requirements.
|
||||||
|
|
||||||
|
The best practices on this page have a priority,
|
||||||
|
which reflects how strongly the Flutter team recommends it.
|
||||||
|
|
||||||
|
* **Strongly recommend:** You should always implement this recommendation if
|
||||||
|
you're starting to build a new application. You should strongly consider
|
||||||
|
refactoring an existing app to implement this practice unless doing so would
|
||||||
|
fundamentally clash with your current approach.
|
||||||
|
* **Recommend**: This practice will likely improve your app.
|
||||||
|
* **Conditional**: This practice can improve your app in certain circumstances.
|
||||||
|
|
||||||
|
### Separation of concerns
|
||||||
|
|
||||||
|
You should separate your app into a UI layer and a data layer. Within those layers, you should further separate logic into classes by responsibility.
|
||||||
|
|
||||||
|
#### Use clearly defined data and UI layers.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Separation of concerns is the most important architectural principle.
|
||||||
|
The data layer exposes application data to the rest of the app, and contains most of the business logic in your application.
|
||||||
|
The UI layer displays application data and listens for user events from users. The UI layer contains separate classes for UI logic and widgets.
|
||||||
|
|
||||||
|
#### Use the repository pattern in the data layer.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
The repository pattern is a software design pattern that isolates the data access logic from the rest of the application.
|
||||||
|
It creates an abstraction layer between the application's business logic and the underlying data storage mechanisms (databases, APIs, file systems, etc.).
|
||||||
|
In practice, this means creating Repository classes and Service classes.
|
||||||
|
|
||||||
|
#### Use ViewModels and Views in the UI layer. (MVVM)
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Separation of concerns is the most important architectural principle.
|
||||||
|
This particular separation makes your code much less error prone because your widgets remain "dumb".
|
||||||
|
|
||||||
|
#### Use `ChangeNotifiers` and `Listenables` to handle widget updates.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> There are many options to handle state-management, and ultimately the decision comes down to personal preference.
|
||||||
|
|
||||||
|
The `ChangeNotifier` API is part of the Flutter SDK, and is a convenient way to have your widgets observe changes in your ViewModels.
|
||||||
|
|
||||||
|
#### Do not put logic in widgets.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Logic should be encapsulated in methods on the ViewModel. The only logic a view should contain is:
|
||||||
|
* Simple if-statements to show and hide widgets based on a flag or nullable field in the ViewModel
|
||||||
|
* Animation logic that relies on the widget to calculate
|
||||||
|
* Layout logic based on device information, like screen size or orientation.
|
||||||
|
* Simple routing logic
|
||||||
|
|
||||||
|
#### Use a domain layer.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> Use in apps with complex logic requirements.
|
||||||
|
|
||||||
|
A domain layer is only needed if your application has exceeding complex logic that crowds your ViewModels,
|
||||||
|
or if you find yourself repeating logic in ViewModels.
|
||||||
|
In very large apps, use-cases are useful, but in most apps they add unnecessary overhead.
|
||||||
|
|
||||||
|
### Handling data
|
||||||
|
|
||||||
|
Handling data with care makes your code easier to understand, less error prone, and
|
||||||
|
prevents malformed or unexpected data from being created.
|
||||||
|
|
||||||
|
#### Use unidirectional data flow.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Data updates should only flow from the data layer to the UI layer.
|
||||||
|
Interactions in the UI layer are sent to the data layer where they're processed.
|
||||||
|
|
||||||
|
#### Use `Commands` to handle events from user interaction.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
Commands prevent rendering errors in your app, and standardize how the UI layer sends events to the data layer.
|
||||||
|
|
||||||
|
#### Use immutable data models.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Immutable data is crucial in ensuring that any necessary changes occur only in the proper place, usually the data or domain layer.
|
||||||
|
Because immutable objects can't be modified after creation, you must create a new instance to reflect changes.
|
||||||
|
This process prevents accidental updates in the UI layer and supports a clear, unidirectional data flow.
|
||||||
|
|
||||||
|
#### Use freezed or built_value to generate immutable data models.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
You can use packages to help generate useful functionality in your data models, `freezed` or `built_value`.
|
||||||
|
These can generate common model methods like JSON ser/des, deep equality checking and copy methods.
|
||||||
|
These code generation packages can add significant build time to your applications if you have a lot of models.
|
||||||
|
|
||||||
|
#### Create separate API models and domain models.
|
||||||
|
**Conditional**
|
||||||
|
|
||||||
|
> Use in large apps.
|
||||||
|
|
||||||
|
Using separate models adds verbosity, but prevents complexity in ViewModels and use-cases.
|
||||||
|
|
||||||
|
### App structure
|
||||||
|
|
||||||
|
Well organized code benefits both the health of the app itself, and the team working on the code.
|
||||||
|
|
||||||
|
#### Use dependency injection.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Dependency injection prevents your app from having globally accessible objects, which makes your code less error prone.
|
||||||
|
We recommend you use the `provider` package to handle dependency injection.
|
||||||
|
|
||||||
|
#### Use `go_router` for navigation.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
Go_router is the preferred way to write 90% of Flutter applications.
|
||||||
|
There are some specific use-cases that go_router doesn't solve,
|
||||||
|
in which case you can use the `Flutter Navigator API` directly or try other packages found on `pub.dev`.
|
||||||
|
|
||||||
|
#### Use standardized naming conventions for classes, files and directories.
|
||||||
|
**Recommend**
|
||||||
|
|
||||||
|
We recommend naming classes for the architectural component they represent.
|
||||||
|
For example, you may have the following classes:
|
||||||
|
|
||||||
|
* HomeViewModel
|
||||||
|
* HomeScreen
|
||||||
|
* UserRepository
|
||||||
|
* ClientApiService
|
||||||
|
|
||||||
|
For clarity, we do not recommend using names that can be confused with objects from the Flutter SDK.
|
||||||
|
For example, you should put your shared widgets in a directory called `ui/core/`,
|
||||||
|
rather than a directory called `/widgets`.
|
||||||
|
|
||||||
|
#### Use abstract repository classes
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Repository classes are the sources of truth for all data in your app,
|
||||||
|
and facilitate communication with external APIs.
|
||||||
|
Creating abstract repository classes allows you to create different implementations,
|
||||||
|
which can be used for different app environments, such as "development" and "staging".
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Good testing practices makes your app flexible.
|
||||||
|
It also makes it straightforward and low risk to add new logic and new UI.
|
||||||
|
|
||||||
|
#### Test architectural components separately, and together.
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
* Write unit tests for every service, repository and ViewModel class. These tests should test the logic of every method individually.
|
||||||
|
* Write widget tests for views. Testing routing and dependency injection are particularly important.
|
||||||
|
|
||||||
|
#### Make fakes for testing (and write code that takes advantage of fakes.)
|
||||||
|
**Strongly recommend**
|
||||||
|
|
||||||
|
Fakes aren't concerned with the inner workings of any given method as much
|
||||||
|
as they're concerned with inputs and outputs. If you have this in mind while writing application code,
|
||||||
|
you're forced to write modular, lightweight functions and classes with well defined inputs and outputs.
|
||||||
|
|
||||||
|
### Deploying Firebase
|
||||||
|
You should not use Firebase CLI. You have to ask the user for deploying or modifying something.
|
||||||
2
em2rp/.gitignore
vendored
2
em2rp/.gitignore
vendored
@@ -44,4 +44,4 @@ app.*.map.json
|
|||||||
|
|
||||||
# Environment configuration with credentials
|
# Environment configuration with credentials
|
||||||
lib/config/env.dev.dart
|
lib/config/env.dev.dart
|
||||||
|
functions/.env
|
||||||
|
|||||||
43
em2rp/CHANGELOG.md
Normal file
43
em2rp/CHANGELOG.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Changelog - EM2RP
|
||||||
|
|
||||||
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 24/02/2026
|
||||||
|
Ajout de la gestion des maintenance et synthèse vocale
|
||||||
|
|
||||||
|
## 18/02/2026
|
||||||
|
Ajout de la fonctionnalité d'exportation des données au format CSV. Correction de bugs mineurs et amélioration des performances.
|
||||||
|
|
||||||
|
## 🚀 Nouveautés de la mise à jour
|
||||||
|
|
||||||
|
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
|
||||||
|
|
||||||
|
* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.
|
||||||
|
* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.
|
||||||
|
* **Checklist de Préparation 2.0 :** L'interface de préparation a été repensée. Elle regroupe désormais les objets par conteneurs et permet de suivre visuellement les équipements manquants ou perdus à chaque étape (chargement, retour, etc.).
|
||||||
|
* **Sélecteur de Matériel Optimisé :** La recherche de matériel pour un événement est beaucoup plus rapide. Vous pouvez désormais masquer automatiquement les équipements déjà utilisés sur d'autres événements aux mêmes dates.
|
||||||
|
* **Gestion & Administration :** Affichage clair des prix HT/TTC partout dans l'application. Pour les administrateurs, l'ajout d'utilisateurs et la réinitialisation de mot de passe sont simplifiés via l'envoi d'emails automatiques.
|
||||||
|
### Ajouté
|
||||||
|
- Système de vérification automatique des mises à jour
|
||||||
|
- Dialog de notification de mise à jour avec notes de version
|
||||||
|
- Rechargement automatique du cache après mise à jour
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Scanner QR Code pour équipements et conteneurs
|
||||||
|
- Génération de QR codes pour conteneurs
|
||||||
|
- Indicateur de chargement pour génération QR
|
||||||
|
- Sections repliables dans le dialog de sélection d'équipement
|
||||||
|
- Filtrage des équipements en conflit
|
||||||
|
- Filtrage des boîtes par catégorie
|
||||||
|
|
||||||
|
### Amélioré
|
||||||
|
- Performance du dialog de sélection d'équipement
|
||||||
|
- Gestion du cache des équipements
|
||||||
|
- Interface utilisateur générale
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Problème de cache avec les équipements non affichés
|
||||||
|
- Bouton de validation désactivé dans certains cas
|
||||||
|
|
||||||
BIN
em2rp/assets/sounds/error.mp3
Normal file
BIN
em2rp/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
em2rp/assets/sounds/ok.mp3
Normal file
BIN
em2rp/assets/sounds/ok.mp3
Normal file
Binary file not shown.
@@ -25,6 +25,16 @@ if %ERRORLEVEL% NEQ 0 (
|
|||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
echo [1.5/4] Mise à jour du fichier version.json...
|
||||||
|
node scripts\update_version_json.js
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors de la mise à jour de version.json
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
echo [2/4] Build Flutter Web...
|
echo [2/4] Build Flutter Web...
|
||||||
call flutter build web --release
|
call flutter build web --release
|
||||||
if %ERRORLEVEL% NEQ 0 (
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
|||||||
32
em2rp/deploy_alert_corrections.ps1
Normal file
32
em2rp/deploy_alert_corrections.ps1
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Script de déploiement rapide - Corrections Alertes
|
||||||
|
|
||||||
|
Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Hot restart Flutter (si app en cours)
|
||||||
|
Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 2. Pub get
|
||||||
|
Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 3. Optionnel : Redéployer les fonctions si besoin
|
||||||
|
# Décommentez si vous avez modifié les Cloud Functions
|
||||||
|
# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow
|
||||||
|
# firebase deploy --only functions:sendAlertEmail
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan
|
||||||
|
Write-Host "1. Hot restart de l'application (R dans terminal Flutter)"
|
||||||
|
Write-Host "2. Vérifier que vous êtes connecté"
|
||||||
|
Write-Host "3. Créer un événement de test avec workforce"
|
||||||
|
Write-Host "4. Créer une alerte LOST (équipement perdu)"
|
||||||
|
Write-Host "5. Vérifier les logs (F12 → Console)"
|
||||||
|
Write-Host "6. Vérifier Firestore (Firebase Console)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow
|
||||||
|
|
||||||
25
em2rp/deploy_alert_trigger.ps1
Normal file
25
em2rp/deploy_alert_trigger.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Script de déploiement de la fonction onAlertCreated
|
||||||
|
Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Vérifier que nous sommes dans le bon répertoire
|
||||||
|
$currentPath = Get-Location
|
||||||
|
if ($currentPath.Path -notlike "*\em2rp") {
|
||||||
|
Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# S'assurer qu'on utilise le bon projet
|
||||||
|
Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow
|
||||||
|
firebase use em2rp-951dc
|
||||||
|
|
||||||
|
# Déployer la fonction
|
||||||
|
Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow
|
||||||
|
firebase deploy --only functions:onAlertCreated
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "`nDéploiement réussi!" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "`nÉchec du déploiement" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
109
em2rp/deploy_backend.ps1
Normal file
109
em2rp/deploy_backend.ps1
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Script de déploiement backend sécurisé
|
||||||
|
# Usage: .\deploy_backend.ps1 [test|prod]
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[ValidateSet("test", "prod")]
|
||||||
|
[string]$mode
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Migration Backend - Déploiement" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Mode TEST : Lancer les émulateurs
|
||||||
|
if ($mode -eq "test") {
|
||||||
|
Write-Host "Mode: TEST (émulateurs)" -ForegroundColor Yellow
|
||||||
|
Write-Host "Lancement des émulateurs Firebase..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
firebase emulators:start
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode PROD : Déploiement en production
|
||||||
|
Write-Host "Mode: PRODUCTION" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Confirmation
|
||||||
|
Write-Host "ATTENTION: Vous allez déployer en PRODUCTION !" -ForegroundColor Red
|
||||||
|
$confirmation = Read-Host "Tapez 'OUI' pour confirmer"
|
||||||
|
|
||||||
|
if ($confirmation -ne "OUI") {
|
||||||
|
Write-Host "Déploiement annulé." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 1/4 : Vérification du code" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Vérifier que ApiConfig est en mode production
|
||||||
|
$apiConfigPath = "lib\config\api_config.dart"
|
||||||
|
$apiConfigContent = Get-Content $apiConfigPath -Raw
|
||||||
|
|
||||||
|
if ($apiConfigContent -match "isDevelopment = true") {
|
||||||
|
Write-Host "ERREUR: ApiConfig est en mode développement !" -ForegroundColor Red
|
||||||
|
Write-Host "Veuillez mettre 'isDevelopment = false' dans $apiConfigPath" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ ApiConfig en mode production" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 2/4 : Installation dépendances" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
Push-Location functions
|
||||||
|
npm install
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Installation des dépendances échouée" -ForegroundColor Red
|
||||||
|
Pop-Location
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
Write-Host "✓ Dépendances installées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 3/4 : Déploiement Cloud Functions" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
firebase deploy --only functions
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Déploiement des functions échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Cloud Functions déployées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 4/4 : Déploiement Firestore Rules" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
firebase deploy --only firestore:rules
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Déploiement des règles échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Firestore Rules déployées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. Tester les opérations CRUD (voir TESTING_PLAN.md)" -ForegroundColor Gray
|
||||||
|
Write-Host "2. Surveiller les logs: firebase functions:log" -ForegroundColor Gray
|
||||||
|
Write-Host "3. Vérifier les permissions utilisateurs" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Console Firebase:" -ForegroundColor Cyan
|
||||||
|
Write-Host "https://console.firebase.google.com/project/em2rp-951dc/functions" -ForegroundColor Blue
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
85
em2rp/deploy_firestore_rules.ps1
Normal file
85
em2rp/deploy_firestore_rules.ps1
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Script de déploiement des règles Firestore
|
||||||
|
# Date : 15/01/2026
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier que Firebase CLI est installé
|
||||||
|
Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow
|
||||||
|
$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $firebaseCmd) {
|
||||||
|
Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installation requise :" -ForegroundColor Yellow
|
||||||
|
Write-Host " npm install -g firebase-tools" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier que le fichier firestore.rules existe
|
||||||
|
if (-Not (Test-Path "firestore.rules")) {
|
||||||
|
Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red
|
||||||
|
Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Afficher un aperçu des règles pour les alertes
|
||||||
|
Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow
|
||||||
|
Write-Host "------------------------------" -ForegroundColor Gray
|
||||||
|
Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10
|
||||||
|
Write-Host "------------------------------" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Demander confirmation
|
||||||
|
Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline
|
||||||
|
Write-Host " " -NoNewline
|
||||||
|
$confirmation = Read-Host
|
||||||
|
|
||||||
|
if ($confirmation -ne "O" -and $confirmation -ne "o") {
|
||||||
|
Write-Host "Déploiement annulé" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Déploiement en cours..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Déployer les règles
|
||||||
|
try {
|
||||||
|
firebase deploy --only firestore:rules
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Créer un événement pour tester" -ForegroundColor White
|
||||||
|
Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Solutions :" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White
|
||||||
|
Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White
|
||||||
|
Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
66
em2rp/deploy_functions.ps1
Normal file
66
em2rp/deploy_functions.ps1
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# EM2RP - Déploiement automatique du système d'alertes
|
||||||
|
# Ce script déploie les Cloud Functions et vérifie le déploiement
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier qu'on est dans le bon répertoire
|
||||||
|
if (-not (Test-Path ".\firebase.json")) {
|
||||||
|
Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vérifier que le fichier .env existe
|
||||||
|
if (-not (Test-Path ".\functions\.env")) {
|
||||||
|
Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red
|
||||||
|
Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Déployer les fonctions
|
||||||
|
Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan
|
||||||
|
Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$deployResult = firebase deploy --only functions 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Lister les fonctions déployées
|
||||||
|
Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan
|
||||||
|
firebase functions:list
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White
|
||||||
|
Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White
|
||||||
|
Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Erreur rencontrée:" -ForegroundColor Yellow
|
||||||
|
Write-Host $deployResult -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow
|
||||||
|
Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White
|
||||||
|
Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White
|
||||||
|
Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
103
em2rp/deploy_hosting.ps1
Normal file
103
em2rp/deploy_hosting.ps1
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Script de déploiement du hosting Firebase
|
||||||
|
# Ce script construit l'application et la déploie sur Firebase Hosting
|
||||||
|
|
||||||
|
Write-Host "=== Déploiement Firebase Hosting ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Vérifier que nous sommes dans le bon dossier
|
||||||
|
if (!(Test-Path "pubspec.yaml")) {
|
||||||
|
Write-Host "ERREUR: Ce script doit être exécuté depuis la racine du projet Flutter" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Construire l'application Flutter pour le web
|
||||||
|
Write-Host "Étape 1/3: Construction de l'application Flutter pour le web..." -ForegroundColor Yellow
|
||||||
|
flutter build web
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: La construction de l'application a échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Application construite avec succès" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 3. Vérifier que version.json existe
|
||||||
|
if (!(Test-Path "build/web/version.json")) {
|
||||||
|
Write-Host "AVERTISSEMENT: version.json n'a pas été copié dans build/web/" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Copier manuellement si nécessaire
|
||||||
|
if (Test-Path "web/version.json") {
|
||||||
|
Write-Host " → Copie de web/version.json vers build/web/..." -ForegroundColor Yellow
|
||||||
|
Copy-Item "web/version.json" "build/web/version.json"
|
||||||
|
Write-Host "✓ Fichier copié" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "ERREUR: web/version.json n'existe pas" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 4. Afficher la version qui va être déployée
|
||||||
|
$versionContent = Get-Content "build/web/version.json" | ConvertFrom-Json
|
||||||
|
Write-Host "Version à déployer: $($versionContent.version)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Force update: $($versionContent.forceUpdate)" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 5. Demander confirmation
|
||||||
|
$confirm = Read-Host "Voulez-vous déployer sur Firebase Hosting ? (o/n)"
|
||||||
|
if ($confirm -ne "o" -and $confirm -ne "O") {
|
||||||
|
Write-Host "Déploiement annulé" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 6. Déployer sur Firebase Hosting
|
||||||
|
Write-Host "Étape 2/3: Déploiement sur Firebase Hosting..." -ForegroundColor Yellow
|
||||||
|
firebase deploy --only hosting
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Le déploiement a échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Déploiement réussi" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 7. Vérifier que version.json est accessible
|
||||||
|
Write-Host "Étape 3/3: Vérification de l'accès à version.json..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "https://app.em2events.fr/version.json" -Method GET -UseBasicParsing
|
||||||
|
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host "✓ version.json est accessible" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Vérifier les en-têtes CORS
|
||||||
|
if ($response.Headers["Access-Control-Allow-Origin"]) {
|
||||||
|
Write-Host "✓ En-têtes CORS configurés correctement" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ ATTENTION: En-têtes CORS non détectés" -ForegroundColor Yellow
|
||||||
|
Write-Host " Les en-têtes peuvent prendre quelques minutes pour se propager" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Afficher la version déployée
|
||||||
|
$deployedVersion = ($response.Content | ConvertFrom-Json).version
|
||||||
|
Write-Host "Version déployée: $deployedVersion" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ Code de statut: $($response.StatusCode)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Impossible de vérifier l'accès à version.json" -ForegroundColor Yellow
|
||||||
|
Write-Host " Erreur: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
Write-Host " Le fichier peut prendre quelques minutes pour être accessible" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Déploiement terminé ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Les utilisateurs recevront une notification de mise à jour au prochain chargement de l'application." -ForegroundColor Green
|
||||||
|
Write-Host "URL de l'application: https://app.em2events.fr" -ForegroundColor Cyan
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Export vers Google Calendar
|
|
||||||
|
|
||||||
## Fonctionnalité
|
|
||||||
|
|
||||||
L'application permet d'exporter un événement au format ICS (iCalendar), compatible avec Google Calendar, Apple Calendar, Outlook et la plupart des applications de calendrier.
|
|
||||||
|
|
||||||
## Utilisation
|
|
||||||
|
|
||||||
1. Ouvrir les détails d'un événement
|
|
||||||
2. Cliquer sur l'icône de calendrier 📅 dans l'en-tête
|
|
||||||
3. Le fichier `.ics` sera automatiquement téléchargé
|
|
||||||
4. Ouvrir le fichier pour l'importer dans votre application de calendrier
|
|
||||||
|
|
||||||
## Informations exportées
|
|
||||||
|
|
||||||
Le fichier ICS contient :
|
|
||||||
|
|
||||||
### Informations principales
|
|
||||||
- **Titre** : Nom de l'événement
|
|
||||||
- **Date de début** : Date et heure de début
|
|
||||||
- **Date de fin** : Date et heure de fin
|
|
||||||
- **Lieu** : Adresse de l'événement
|
|
||||||
- **Statut** : Confirmé / Annulé / En attente
|
|
||||||
|
|
||||||
### Description détaillée
|
|
||||||
- Type d'événement
|
|
||||||
- Description complète
|
|
||||||
- Jauge (nombre de personnes)
|
|
||||||
- Email de contact
|
|
||||||
- Téléphone de contact
|
|
||||||
- Temps d'installation et démontage
|
|
||||||
- Liste de la main d'œuvre
|
|
||||||
- Options sélectionnées (avec quantités)
|
|
||||||
- Prix de base
|
|
||||||
|
|
||||||
## Format du fichier
|
|
||||||
|
|
||||||
Le fichier généré suit le standard **RFC 5545** (iCalendar) et est nommé selon le format :
|
|
||||||
```
|
|
||||||
event_[nom_evenement]_[date].ics
|
|
||||||
```
|
|
||||||
|
|
||||||
Exemple : `event_Concert_Mairie_20251225.ics`
|
|
||||||
|
|
||||||
## Compatibilité
|
|
||||||
|
|
||||||
✅ Google Calendar
|
|
||||||
✅ Apple Calendar (macOS, iOS)
|
|
||||||
✅ Microsoft Outlook
|
|
||||||
✅ Thunderbird
|
|
||||||
✅ Autres applications supportant le format ICS
|
|
||||||
|
|
||||||
## Import dans Google Calendar
|
|
||||||
|
|
||||||
1. Télécharger le fichier `.ics`
|
|
||||||
2. Ouvrir Google Calendar
|
|
||||||
3. Cliquer sur l'icône ⚙️ (Paramètres)
|
|
||||||
4. Sélectionner "Importation et exportation"
|
|
||||||
5. Cliquer sur "Sélectionner un fichier sur votre ordinateur"
|
|
||||||
6. Choisir le fichier `.ics` téléchargé
|
|
||||||
7. Sélectionner le calendrier de destination
|
|
||||||
8. Cliquer sur "Importer"
|
|
||||||
|
|
||||||
## Notes techniques
|
|
||||||
|
|
||||||
- Les dates sont converties en UTC pour assurer la compatibilité internationale
|
|
||||||
- Les caractères spéciaux sont correctement échappés selon le standard ICS
|
|
||||||
- Un UID unique est généré pour chaque événement (`em2rp-[eventId]@em2rp.app`)
|
|
||||||
- Le fichier est encodé en UTF-8
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//EM2RP//Event Manager//FR
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
METHOD:PUBLISH
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:em2rp-example123@em2rp.app
|
|
||||||
DTSTAMP:20251220T120000Z
|
|
||||||
DTSTART:20251225T190000Z
|
|
||||||
DTEND:20251225T230000Z
|
|
||||||
SUMMARY:Concert de Noël
|
|
||||||
DESCRIPTION:TYPE: Concert\n\nDESCRIPTION:\nConcert de Noël avec orchestre symphonique et chorale.\n\nJAUGE: 500 personnes\nEMAIL DE CONTACT: contact@example.com\nTÉLÉPHONE DE CONTACT: 06 12 34 56 78\n\nADRESSE: Salle des fêtes\, Place de la Mairie\, 75001 Paris\n\nINSTALLATION: 4h\nDÉMONTAGE: 2h\n\nMAIN D'ŒUVRE:\n - Jean Dupont\n - Marie Martin\n - Pierre Durand\n\nOPTIONS:\n - Système son professionnel\n - Éclairage scénique (x2)\n\nPRIX DE BASE: 2500.00€\n\n---\nGéré par EM2RP Event Manager
|
|
||||||
LOCATION:Salle des fêtes\, Place de la Mairie\, 75001 Paris
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
CATEGORIES:Concert
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
|
|
||||||
@@ -41,6 +41,51 @@
|
|||||||
"firebase.json",
|
"firebase.json",
|
||||||
"**/.*",
|
"**/.*",
|
||||||
"**/node_modules/**"
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "version.json",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Origin",
|
||||||
|
"value": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Access-Control-Allow-Methods",
|
||||||
|
"value": "GET, OPTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "no-cache, no-store, must-revalidate"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "**",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules",
|
||||||
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5051
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8088
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"port": 9199
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 4040
|
||||||
|
},
|
||||||
|
"singleProjectMode": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
em2rp/firestore.indexes.json
Normal file
119
em2rp/firestore.indexes.json
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "alerts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isRead",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "alerts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "containers",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "equipments",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "category",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "EndDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "StartDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
184
em2rp/firestore.rules
Normal file
184
em2rp/firestore.rules
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION
|
||||||
|
// ============================================================================
|
||||||
|
// Date de création : 14 janvier 2026
|
||||||
|
// Objectif : Bloquer tous les accès directs à Firestore depuis les clients
|
||||||
|
// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
|
||||||
|
// ========================================================================
|
||||||
|
// Cette règle empêche tout accès direct depuis les clients (web/mobile)
|
||||||
|
// Les Cloud Functions ont un accès admin et ne sont pas affectées
|
||||||
|
|
||||||
|
match /{document=**} {
|
||||||
|
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||||
|
// ========================================================================
|
||||||
|
// Si vous avez besoin de listeners en temps réel pour certaines collections,
|
||||||
|
// décommentez les règles ci-dessous.
|
||||||
|
//
|
||||||
|
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
|
||||||
|
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Événements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Alertes : Lecture et création pour utilisateurs authentifiés
|
||||||
|
// Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes
|
||||||
|
match /alerts/{alertId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow create: if request.auth != null
|
||||||
|
&& request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom
|
||||||
|
allow update: if request.auth != null
|
||||||
|
&& (
|
||||||
|
// L'utilisateur peut marquer comme lue uniquement s'il est assigné
|
||||||
|
(request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt']))
|
||||||
|
// Ou le backend peut tout modifier (processed, assignedTo, etc.)
|
||||||
|
|| !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy
|
||||||
|
);
|
||||||
|
allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Utilisateurs : Lecture de son propre profil uniquement
|
||||||
|
match /users/{userId} {
|
||||||
|
allow read: if request.auth != null && request.auth.uid == userId;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'événements : Lecture seule
|
||||||
|
match /eventTypes/{typeId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options : Lecture seule
|
||||||
|
match /options/{optionId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients : Lecture seule
|
||||||
|
match /customers/{customerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
|
||||||
|
// ========================================================================
|
||||||
|
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
|
||||||
|
// pour la lecture en temps réel
|
||||||
|
//
|
||||||
|
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
|
||||||
|
// la collection users, ce qui peut impacter les performances et les coûts.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Fonction helper : Récupérer les permissions de l'utilisateur
|
||||||
|
function getUserPermissions() {
|
||||||
|
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper : Vérifier si l'utilisateur a une permission
|
||||||
|
function hasPermission(permission) {
|
||||||
|
return request.auth != null && permission in getUserPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture uniquement si permission view_equipment
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événements : Lecture selon permissions
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if hasPermission('view_events') || hasPermission('edit_event');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture uniquement si permission view_equipment
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture uniquement si permission view_equipment
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTES DE SÉCURITÉ
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
|
||||||
|
// - Bloque TOUS les accès directs depuis les clients
|
||||||
|
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
|
||||||
|
// - C'est la configuration la PLUS SÉCURISÉE
|
||||||
|
//
|
||||||
|
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
|
||||||
|
// - Permettent les listeners en temps réel pour certaines collections
|
||||||
|
// - UNIQUEMENT la LECTURE est autorisée
|
||||||
|
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
|
||||||
|
//
|
||||||
|
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
|
||||||
|
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
|
||||||
|
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
|
||||||
|
// - À utiliser uniquement si nécessaire
|
||||||
|
//
|
||||||
|
// 4. TESTS APRÈS DÉPLOIEMENT
|
||||||
|
// - Vérifier que les Cloud Functions fonctionnent toujours
|
||||||
|
// - Tester qu'un accès direct depuis la console échoue
|
||||||
|
// - Surveiller les logs : firebase functions:log
|
||||||
|
//
|
||||||
|
// 5. ROLLBACK EN CAS DE PROBLÈME
|
||||||
|
// - Remplacer temporairement par :
|
||||||
|
// match /{document=**} {
|
||||||
|
// allow read, write: if request.auth != null;
|
||||||
|
// }
|
||||||
|
// - Déployer rapidement : firebase deploy --only firestore:rules
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
9
em2rp/functions/.env
Normal file
9
em2rp/functions/.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Configuration SMTP pour l'envoi d'emails
|
||||||
|
SMTP_HOST="mail.em2events.fr"
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER="notify@em2events.fr"
|
||||||
|
SMTP_PASS="aL8@Rx8xqFrNij$a"
|
||||||
|
|
||||||
|
# URL de l'application
|
||||||
|
APP_URL="https://app.em2events.fr"
|
||||||
|
|
||||||
2
em2rp/functions/.gitignore
vendored
2
em2rp/functions/.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|||||||
271
em2rp/functions/createAlert.js
Normal file
271
em2rp/functions/createAlert.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
const {onRequest} = require('firebase-functions/v2/https');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
||||||
|
const auth = require('./utils/auth');
|
||||||
|
|
||||||
|
// Configuration CORS
|
||||||
|
const setCorsHeaders = (res, req) => {
|
||||||
|
// Utiliser l'origin de la requête pour permettre les credentials
|
||||||
|
const origin = req.headers.origin || '*';
|
||||||
|
|
||||||
|
res.set('Access-Control-Allow-Origin', origin);
|
||||||
|
|
||||||
|
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||||
|
if (origin !== '*') {
|
||||||
|
res.set('Access-Control-Allow-Credentials', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||||
|
res.set('Access-Control-Max-Age', '3600');
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCors = (handler) => {
|
||||||
|
return async (req, res) => {
|
||||||
|
setCorsHeaders(res, req);
|
||||||
|
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Unhandled error:", error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une alerte et envoie les notifications
|
||||||
|
* Gère tout le processus côté backend de A à Z
|
||||||
|
*/
|
||||||
|
exports.createAlert = onRequest({
|
||||||
|
cors: false,
|
||||||
|
invoker: 'public',
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const data = req.body.data || req.body;
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
actionUrl,
|
||||||
|
metadata,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// Validation des données
|
||||||
|
if (!type || !severity || !message) {
|
||||||
|
res.status(400).json({error: 'type, severity et message sont requis'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Déterminer les utilisateurs à notifier
|
||||||
|
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||||
|
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Créer l'alerte dans Firestore
|
||||||
|
const alertRef = admin.firestore().collection('alerts').doc();
|
||||||
|
const alertData = {
|
||||||
|
id: alertRef.id,
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
title: title || getAlertTitle(type),
|
||||||
|
message,
|
||||||
|
equipmentId: equipmentId || null,
|
||||||
|
eventId: eventId || null,
|
||||||
|
actionUrl: actionUrl || null,
|
||||||
|
metadata: metadata || {},
|
||||||
|
assignedTo: userIds,
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
createdBy: decodedToken.uid,
|
||||||
|
isRead: false,
|
||||||
|
emailSent: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
};
|
||||||
|
|
||||||
|
await alertRef.set(alertData);
|
||||||
|
|
||||||
|
// 3. Envoyer les emails si alerte critique
|
||||||
|
let emailResults = {};
|
||||||
|
if (severity === 'CRITICAL') {
|
||||||
|
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||||
|
|
||||||
|
// Mettre à jour le statut d'envoi
|
||||||
|
await alertRef.update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
emailResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
alertId: alertRef.id,
|
||||||
|
usersNotified: userIds.length,
|
||||||
|
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[createAlert] Erreur:', error);
|
||||||
|
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||||
|
*/
|
||||||
|
async function determineTargetUsers(alertType, severity, eventId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const targetUserIds = new Set();
|
||||||
|
|
||||||
|
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||||
|
const allUsersSnapshot = await db.collection('users').get();
|
||||||
|
|
||||||
|
allUsersSnapshot.forEach((doc) => {
|
||||||
|
const user = doc.data();
|
||||||
|
if (user.role) {
|
||||||
|
// Le rôle peut être une référence Firestore ou une string
|
||||||
|
let rolePath = '';
|
||||||
|
if (typeof user.role === 'string') {
|
||||||
|
rolePath = user.role;
|
||||||
|
} else if (user.role.path) {
|
||||||
|
rolePath = user.role.path;
|
||||||
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
|
rolePath = user.role._path.segments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||||
|
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
||||||
|
targetUserIds.add(doc.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||||
|
if (eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const workforce = event.workforce || [];
|
||||||
|
|
||||||
|
workforce.forEach((member) => {
|
||||||
|
if (member.userId) {
|
||||||
|
targetUserIds.add(member.userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(targetUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie les emails d'alerte à tous les utilisateurs
|
||||||
|
*/
|
||||||
|
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||||
|
const results = {};
|
||||||
|
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||||
|
|
||||||
|
// Envoyer les emails en parallèle (batch de 5)
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.push(userIds.slice(i, i + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const promises = batch.map(async (userId) => {
|
||||||
|
try {
|
||||||
|
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
|
||||||
|
results[userId] = sent;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||||
|
results[userId] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email à un utilisateur spécifique
|
||||||
|
*/
|
||||||
|
async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await db.collection('users').doc(userId).get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la préférence pour ce type d'alerte
|
||||||
|
if (!checkAlertPreference(alertData.type, prefs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Préparer les données du template
|
||||||
|
const templateData = await prepareTemplateData(alertData, user);
|
||||||
|
|
||||||
|
// Rendre le template
|
||||||
|
const html = await renderTemplate('alert-individual', templateData);
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alertData),
|
||||||
|
html: html,
|
||||||
|
text: alertData.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
113
em2rp/functions/migrate_email_prefs.js
Normal file
113
em2rp/functions/migrate_email_prefs.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Script de migration : Active les emails pour tous les utilisateurs existants
|
||||||
|
* À exécuter une seule fois après le déploiement
|
||||||
|
*/
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
// AJOUTER CECI : Charger le fichier de clé
|
||||||
|
const serviceAccount = require('./serviceAccountKey.json');
|
||||||
|
|
||||||
|
// Initialiser Firebase Admin avec les credentials explicites
|
||||||
|
if (!admin.apps.length) {
|
||||||
|
admin.initializeApp({
|
||||||
|
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
||||||
|
projectId: 'em2rp-951dc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active les notifications par email pour tous les utilisateurs existants
|
||||||
|
*/
|
||||||
|
async function migrateEmailPreferences() {
|
||||||
|
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer tous les utilisateurs
|
||||||
|
const usersSnapshot = await db.collection('users').get();
|
||||||
|
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
||||||
|
|
||||||
|
// 2. Préparer les updates
|
||||||
|
const updates = [];
|
||||||
|
let alreadyEnabled = 0;
|
||||||
|
let toUpdate = 0;
|
||||||
|
|
||||||
|
usersSnapshot.forEach((doc) => {
|
||||||
|
const user = doc.data();
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
|
||||||
|
// Vérifier si déjà activé
|
||||||
|
if (prefs.emailEnabled === true) {
|
||||||
|
alreadyEnabled++;
|
||||||
|
console.log(` ○ ${user.email || doc.id}: emails déjà activés`);
|
||||||
|
} else {
|
||||||
|
toUpdate++;
|
||||||
|
console.log(` ✓ ${user.email || doc.id}: activation des emails`);
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
ref: doc.ref,
|
||||||
|
data: {
|
||||||
|
'notificationPreferences.emailEnabled': true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n--- RÉSUMÉ ---`);
|
||||||
|
console.log(` Total utilisateurs: ${usersSnapshot.size}`);
|
||||||
|
console.log(` Déjà activés: ${alreadyEnabled}`);
|
||||||
|
console.log(` À mettre à jour: ${toUpdate}`);
|
||||||
|
|
||||||
|
// 3. Appliquer les mises à jour par batches de 500 (limite Firestore)
|
||||||
|
if (updates.length > 0) {
|
||||||
|
console.log(`\nApplication des mises à jour...`);
|
||||||
|
|
||||||
|
const batchSize = 500;
|
||||||
|
for (let i = 0; i < updates.length; i += batchSize) {
|
||||||
|
const batch = db.batch();
|
||||||
|
const currentBatch = updates.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
currentBatch.forEach((update) => {
|
||||||
|
batch.update(update.ref, update.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
console.log(` ✓ Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(updates.length / batchSize)} appliqué`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✓ Migration terminée avec succès !`);
|
||||||
|
console.log(` ${toUpdate} utilisateurs mis à jour\n`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== FIN MIGRATION ===');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total: usersSnapshot.size,
|
||||||
|
alreadyEnabled,
|
||||||
|
updated: toUpdate,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERREUR MIGRATION:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter la migration si appelé directement
|
||||||
|
if (require.main === module) {
|
||||||
|
migrateEmailPreferences()
|
||||||
|
.then((result) => {
|
||||||
|
console.log('\n✓ Migration réussie:', result);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('\n❌ Migration échouée:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrateEmailPreferences };
|
||||||
|
|
||||||
93
em2rp/functions/migrate_equipment_ids.js
Normal file
93
em2rp/functions/migrate_equipment_ids.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas
|
||||||
|
*
|
||||||
|
* Ce script parcourt tous les documents de la collection 'equipments' et ajoute
|
||||||
|
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const serviceAccount = require('./serviceAccountKey.json');
|
||||||
|
|
||||||
|
// Initialiser Firebase Admin
|
||||||
|
admin.initializeApp({
|
||||||
|
credential: admin.credential.cert(serviceAccount)
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
async function migrateEquipmentIds() {
|
||||||
|
console.log('🔧 Migration: Ajout du champ id aux équipements');
|
||||||
|
console.log('================================================\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer tous les équipements
|
||||||
|
const equipmentsSnapshot = await db.collection('equipments').get();
|
||||||
|
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||||
|
|
||||||
|
let missingIdCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const batch = db.batch();
|
||||||
|
let batchCount = 0;
|
||||||
|
|
||||||
|
for (const doc of equipmentsSnapshot.docs) {
|
||||||
|
const data = doc.data();
|
||||||
|
|
||||||
|
// Vérifier si le champ 'id' est manquant ou vide
|
||||||
|
if (!data.id || data.id === '') {
|
||||||
|
missingIdCount++;
|
||||||
|
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
|
||||||
|
|
||||||
|
// Ajouter au batch
|
||||||
|
batch.update(doc.ref, { id: doc.id });
|
||||||
|
batchCount++;
|
||||||
|
updatedCount++;
|
||||||
|
|
||||||
|
// Exécuter le batch tous les 500 documents (limite Firestore)
|
||||||
|
if (batchCount === 500) {
|
||||||
|
await batch.commit();
|
||||||
|
console.log(`✅ Batch de ${batchCount} documents mis à jour`);
|
||||||
|
batchCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter le dernier batch s'il reste des documents
|
||||||
|
if (batchCount > 0) {
|
||||||
|
await batch.commit();
|
||||||
|
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n================================================');
|
||||||
|
console.log('📊 RÉSUMÉ DE LA MIGRATION');
|
||||||
|
console.log('================================================');
|
||||||
|
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||||
|
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
|
||||||
|
console.log(`Équipements mis à jour: ${updatedCount}`);
|
||||||
|
console.log(`Erreurs: ${errorCount}`);
|
||||||
|
console.log('================================================\n');
|
||||||
|
|
||||||
|
if (missingIdCount === 0) {
|
||||||
|
console.log('✅ Tous les équipements ont déjà un champ id !');
|
||||||
|
} else if (updatedCount === missingIdCount) {
|
||||||
|
console.log('✅ Migration terminée avec succès !');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Migration terminée avec des erreurs');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la migration:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter la migration
|
||||||
|
migrateEquipmentIds()
|
||||||
|
.then(() => {
|
||||||
|
console.log('\n✅ Script terminé');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('\n❌ Script échoué:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
735
em2rp/functions/package-lock.json
generated
735
em2rp/functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,14 @@
|
|||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^6.0.1"
|
"firebase-functions": "^7.0.3",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^8.15.0",
|
||||||
|
|||||||
418
em2rp/functions/processEquipmentValidation.js
Normal file
418
em2rp/functions/processEquipmentValidation.js
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
const {onCall} = require('firebase-functions/v2/https');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
/**
|
||||||
|
* Traite la validation du matériel d'un événement
|
||||||
|
* Appelée par le client lors du chargement/déchargement
|
||||||
|
* Crée automatiquement les alertes nécessaires
|
||||||
|
*/
|
||||||
|
exports.processEquipmentValidation = onCall({
|
||||||
|
cors: true,
|
||||||
|
region: 'europe-west9'
|
||||||
|
}, async (request) => {
|
||||||
|
try {
|
||||||
|
// L'authentification est automatique avec onCall
|
||||||
|
const {auth, data} = request;
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
throw new Error('L\'utilisateur doit être authentifié');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
eventId,
|
||||||
|
equipmentList, // [{equipmentId, status, quantity, etc.}]
|
||||||
|
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!eventId || !equipmentList || !validationType) {
|
||||||
|
throw new Error('eventId, equipmentList et validationType sont requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
const alerts = [];
|
||||||
|
|
||||||
|
// 1. Récupérer les détails de l'événement
|
||||||
|
const eventRef = db.collection('events').doc(eventId);
|
||||||
|
const eventDoc = await eventRef.get();
|
||||||
|
|
||||||
|
if (!eventDoc.exists) {
|
||||||
|
throw new Error('Événement introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const eventName = event.Name || event.name || 'Événement inconnu';
|
||||||
|
const eventDate = formatEventDate(event);
|
||||||
|
|
||||||
|
// 2. Analyser les équipements et détecter les problèmes
|
||||||
|
for (const equipment of equipmentList) {
|
||||||
|
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||||
|
|
||||||
|
// Cas 1: Équipement PERDU
|
||||||
|
if (status === 'LOST') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'LOST',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
title: 'Équipement perdu',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 2: Équipement MANQUANT
|
||||||
|
if (status === 'MISSING') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'EQUIPMENT_MISSING',
|
||||||
|
severity: 'WARNING',
|
||||||
|
title: 'Équipement manquant',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 3: Quantité incorrecte
|
||||||
|
if (expectedQuantity && quantity !== expectedQuantity) {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'QUANTITY_MISMATCH',
|
||||||
|
severity: 'INFO',
|
||||||
|
title: 'Quantité incorrecte',
|
||||||
|
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
expected: expectedQuantity,
|
||||||
|
actual: quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 4: Équipement endommagé
|
||||||
|
if (status === 'DAMAGED') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'DAMAGED',
|
||||||
|
severity: 'WARNING',
|
||||||
|
title: 'Équipement endommagé',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mettre à jour les équipements de l'événement
|
||||||
|
await eventRef.update({
|
||||||
|
equipment: equipmentList,
|
||||||
|
lastValidation: {
|
||||||
|
type: validationType,
|
||||||
|
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
by: auth.uid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Envoyer les notifications pour les alertes critiques
|
||||||
|
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
||||||
|
if (criticalAlerts.length > 0) {
|
||||||
|
for (const alert of criticalAlerts) {
|
||||||
|
try {
|
||||||
|
await sendAlertNotifications(alert, eventId);
|
||||||
|
} catch (notificationError) {
|
||||||
|
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
alertsCreated: alerts.length,
|
||||||
|
criticalAlertsCount: criticalAlerts.length,
|
||||||
|
alertIds: alerts.map((a) => a.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[processEquipmentValidation] Erreur:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une alerte dans Firestore
|
||||||
|
*/
|
||||||
|
async function createAlertInFirestore(alertData) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const alertRef = db.collection('alerts').doc();
|
||||||
|
|
||||||
|
const fullAlertData = {
|
||||||
|
id: alertRef.id,
|
||||||
|
...alertData,
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
isRead: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
emailSent: false,
|
||||||
|
assignedTo: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await alertRef.set(fullAlertData);
|
||||||
|
|
||||||
|
return {...fullAlertData, id: alertRef.id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine les utilisateurs à notifier et envoie les notifications
|
||||||
|
*/
|
||||||
|
async function sendAlertNotifications(alert, eventId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const targetUserIds = new Set();
|
||||||
|
const usersWithPermission = new Set();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||||
|
const allUsersSnapshot = await db.collection('users').get();
|
||||||
|
|
||||||
|
// Créer un map pour stocker les références de rôles à récupérer
|
||||||
|
const roleRefs = new Map();
|
||||||
|
|
||||||
|
for (const doc of allUsersSnapshot.docs) {
|
||||||
|
const user = doc.data();
|
||||||
|
|
||||||
|
if (!user.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire le chemin du rôle
|
||||||
|
let rolePath = '';
|
||||||
|
let roleId = '';
|
||||||
|
|
||||||
|
if (typeof user.role === 'string') {
|
||||||
|
rolePath = user.role;
|
||||||
|
roleId = user.role.split('/').pop();
|
||||||
|
} else if (user.role.path) {
|
||||||
|
rolePath = user.role.path;
|
||||||
|
roleId = user.role.path.split('/').pop();
|
||||||
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
|
rolePath = user.role._path.segments.join('/');
|
||||||
|
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleId && !roleRefs.has(roleId)) {
|
||||||
|
roleRefs.set(roleId, {users: [], rolePath});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleId) {
|
||||||
|
roleRefs.get(roleId).users.push(doc.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Récupérer les permissions de chaque rôle unique
|
||||||
|
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||||
|
try {
|
||||||
|
const roleDoc = await db.collection('roles').doc(roleId).get();
|
||||||
|
|
||||||
|
if (roleDoc.exists) {
|
||||||
|
const roleData = roleDoc.data();
|
||||||
|
const permissions = roleData.permissions || [];
|
||||||
|
|
||||||
|
// Vérifier si le rôle a la permission view_all_events
|
||||||
|
if (permissions.includes('view_all_events')) {
|
||||||
|
users.forEach((userId) => {
|
||||||
|
usersWithPermission.add(userId);
|
||||||
|
targetUserIds.add(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ajouter la workforce de l'événement
|
||||||
|
if (eventId) {
|
||||||
|
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const workforce = event.workforce || [];
|
||||||
|
|
||||||
|
workforce.forEach((member) => {
|
||||||
|
// Extraire l'userId selon différentes structures possibles
|
||||||
|
let userId = null;
|
||||||
|
|
||||||
|
if (typeof member === 'string') {
|
||||||
|
userId = member;
|
||||||
|
} else if (member.userId) {
|
||||||
|
userId = member.userId;
|
||||||
|
} else if (member.id) {
|
||||||
|
userId = member.id;
|
||||||
|
} else if (member.user) {
|
||||||
|
if (typeof member.user === 'string') {
|
||||||
|
userId = member.user;
|
||||||
|
} else if (member.user.id) {
|
||||||
|
userId = member.user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
targetUserIds.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = Array.from(targetUserIds);
|
||||||
|
|
||||||
|
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||||
|
await db.collection('alerts').doc(alert.id).update({
|
||||||
|
assignedTo: userIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Envoyer les emails si alerte critique
|
||||||
|
if (alert.severity === 'CRITICAL') {
|
||||||
|
await sendAlertEmails(alert, userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userIds;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendAlertNotifications] Erreur:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie les emails d'alerte
|
||||||
|
*/
|
||||||
|
async function sendAlertEmails(alert, userIds) {
|
||||||
|
try {
|
||||||
|
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
// Vérifier que EMAIL_CONFIG est disponible
|
||||||
|
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||||
|
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
// Envoyer les emails par lots de 5
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.push(userIds.slice(i, i + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const promises = batch.map(async (userId) => {
|
||||||
|
try {
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await db.collection('users').doc(userId).get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer et envoyer l'email
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
const templateData = await prepareTemplateData(alert, user);
|
||||||
|
html = await renderTemplate('alert-individual', templateData);
|
||||||
|
} catch (templateError) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||||
|
html = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
||||||
|
<p>${alert.message}</p>
|
||||||
|
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
html: html,
|
||||||
|
text: alert.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
successCount += results.filter((r) => r).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'alerte
|
||||||
|
await db.collection('alerts').doc(alert.id).update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
emailsSentCount: successCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return successCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendAlertEmails] Erreur globale:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate la date d'un événement
|
||||||
|
*/
|
||||||
|
function formatEventDate(event) {
|
||||||
|
if (event.startDate) {
|
||||||
|
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
|
||||||
|
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
|
||||||
|
}
|
||||||
|
return 'Date inconnue';
|
||||||
|
}
|
||||||
|
|
||||||
265
em2rp/functions/sendAlertEmail.js
Normal file
265
em2rp/functions/sendAlertEmail.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
const {onCall} = require('firebase-functions/v2/https');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const handlebars = require('handlebars');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email d'alerte à un utilisateur
|
||||||
|
* Appelé par le client Dart via callable function
|
||||||
|
*/
|
||||||
|
exports.sendAlertEmail = onCall({
|
||||||
|
region: 'europe-west9',
|
||||||
|
cors: true
|
||||||
|
}, async (request) => {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
if (!request.auth) {
|
||||||
|
throw new Error('L\'utilisateur doit être authentifié');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {alertId, userId, templateType} = request.data;
|
||||||
|
|
||||||
|
if (!alertId || !userId) {
|
||||||
|
throw new Error('alertId et userId sont requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer l'alerte depuis Firestore
|
||||||
|
const alertDoc = await admin.firestore()
|
||||||
|
.collection('alerts')
|
||||||
|
.doc(alertId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!alertDoc.exists) {
|
||||||
|
throw new Error('Alerte introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const alert = alertDoc.data();
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
throw new Error('Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email de l'utilisateur
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||||
|
return {success: true, skipped: true, reason: 'email_disabled'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la préférence pour ce type d'alerte
|
||||||
|
const alertType = alert.type;
|
||||||
|
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||||
|
if (!shouldSend) {
|
||||||
|
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||||
|
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les données pour le template
|
||||||
|
const templateData = await prepareTemplateData(alert, user);
|
||||||
|
|
||||||
|
// Rendre le template HTML
|
||||||
|
const html = await renderTemplate(
|
||||||
|
templateType || 'alert-individual',
|
||||||
|
templateData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurer le transporteur SMTP
|
||||||
|
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
html: html,
|
||||||
|
// Fallback texte brut
|
||||||
|
text: alert.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Email envoyé:', info.messageId);
|
||||||
|
|
||||||
|
// Marquer l'email comme envoyé dans l'alerte
|
||||||
|
await alertDoc.ref.update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur envoi email:', error);
|
||||||
|
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||||
|
*/
|
||||||
|
function checkAlertPreference(alertType, preferences) {
|
||||||
|
const typeMapping = {
|
||||||
|
'EVENT_CREATED': 'eventsNotifications',
|
||||||
|
'EVENT_MODIFIED': 'eventsNotifications',
|
||||||
|
'EVENT_CANCELLED': 'eventsNotifications',
|
||||||
|
'LOST': 'equipmentNotifications',
|
||||||
|
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||||
|
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||||
|
'STOCK_LOW': 'stockNotifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefKey = typeMapping[alertType];
|
||||||
|
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare les données pour le template
|
||||||
|
*/
|
||||||
|
async function prepareTemplateData(alert, user) {
|
||||||
|
const data = {
|
||||||
|
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||||
|
'Utilisateur',
|
||||||
|
alertTitle: getAlertTitle(alert.type),
|
||||||
|
alertMessage: alert.message,
|
||||||
|
isCritical: alert.severity === 'CRITICAL',
|
||||||
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||||
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter des détails selon le type d'alerte
|
||||||
|
if (alert.eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await admin.firestore()
|
||||||
|
.collection('events')
|
||||||
|
.doc(alert.eventId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
data.eventName = event.Name;
|
||||||
|
if (event.StartDateTime) {
|
||||||
|
const date = event.StartDateTime.toDate();
|
||||||
|
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération événement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert.equipmentId) {
|
||||||
|
try {
|
||||||
|
const eqDoc = await admin.firestore()
|
||||||
|
.collection('equipments')
|
||||||
|
.doc(alert.equipmentId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
data.equipmentName = eqDoc.data().name;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération équipement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre de l'email selon le type d'alerte
|
||||||
|
*/
|
||||||
|
function getEmailSubject(alert) {
|
||||||
|
const subjects = {
|
||||||
|
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||||
|
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||||
|
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||||
|
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||||
|
'STOCK_LOW': '📦 Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre pour le corps de l'email
|
||||||
|
*/
|
||||||
|
function getAlertTitle(type) {
|
||||||
|
const titles = {
|
||||||
|
'EVENT_CREATED': 'Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'EVENT_CANCELLED': 'Événement annulé',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||||
|
'STOCK_LOW': 'Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[type] || 'Nouvelle alerte';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend un template HTML avec Handlebars
|
||||||
|
*/
|
||||||
|
async function renderTemplate(templateName, data) {
|
||||||
|
try {
|
||||||
|
// Lire le template de base
|
||||||
|
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
||||||
|
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||||
|
|
||||||
|
// Lire le template de contenu
|
||||||
|
const contentPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'templates',
|
||||||
|
`${templateName}.html`,
|
||||||
|
);
|
||||||
|
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||||
|
|
||||||
|
// Compiler les templates
|
||||||
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
|
const compileBase = handlebars.compile(baseTemplate);
|
||||||
|
|
||||||
|
// Rendre le contenu
|
||||||
|
const renderedContent = compileContent(data);
|
||||||
|
|
||||||
|
// Rendre le template de base avec le contenu
|
||||||
|
return compileBase({
|
||||||
|
...data,
|
||||||
|
content: renderedContent,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur rendu template:', error);
|
||||||
|
// Fallback vers un template simple
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${data.alertTitle}</h2>
|
||||||
|
<p>${data.alertMessage}</p>
|
||||||
|
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
267
em2rp/functions/sendDailyDigest.js
Normal file
267
em2rp/functions/sendDailyDigest.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
|
||||||
|
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { getSmtpConfig } = require('./utils/emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonction principale : envoie le digest quotidien
|
||||||
|
*/
|
||||||
|
async function sendDailyDigest() {
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer tous les utilisateurs avec email activé
|
||||||
|
const usersSnapshot = await db.collection('users').get();
|
||||||
|
const eligibleUsers = [];
|
||||||
|
|
||||||
|
usersSnapshot.forEach((doc) => {
|
||||||
|
const user = doc.data();
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur a activé les emails
|
||||||
|
if (prefs.emailEnabled !== false && user.email) {
|
||||||
|
eligibleUsers.push({
|
||||||
|
uid: doc.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName || 'Utilisateur',
|
||||||
|
lastName: user.lastName || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
|
||||||
|
|
||||||
|
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
let emailsSent = 0;
|
||||||
|
|
||||||
|
for (const user of eligibleUsers) {
|
||||||
|
try {
|
||||||
|
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||||||
|
const alertsSnapshot = await db.collection('alerts')
|
||||||
|
.where('assignedTo', 'array-contains', user.uid)
|
||||||
|
.where('isRead', '==', false)
|
||||||
|
.where('createdAt', '>=', yesterday)
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (alertsSnapshot.empty) {
|
||||||
|
continue; // Pas d'alertes non lues pour cet utilisateur
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = [];
|
||||||
|
alertsSnapshot.forEach((doc) => {
|
||||||
|
alerts.push({ id: doc.id, ...doc.data() });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||||||
|
|
||||||
|
// 3. Envoyer l'email de digest
|
||||||
|
const sent = await sendDigestEmail(transporter, user, alerts);
|
||||||
|
if (sent) {
|
||||||
|
emailsSent++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||||||
|
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
||||||
|
|
||||||
|
return { success: true, emailsSent };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendDailyDigest] Erreur globale:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie l'email de digest à un utilisateur
|
||||||
|
*/
|
||||||
|
async function sendDigestEmail(transporter, user, alerts) {
|
||||||
|
try {
|
||||||
|
// Grouper les alertes par sévérité
|
||||||
|
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
||||||
|
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
||||||
|
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
||||||
|
|
||||||
|
// Construire le HTML
|
||||||
|
const html = buildDigestHtml(user, {
|
||||||
|
critical: criticalAlerts,
|
||||||
|
warning: warningAlerts,
|
||||||
|
info: infoAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
|
||||||
|
to: user.email,
|
||||||
|
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le HTML du digest
|
||||||
|
*/
|
||||||
|
function buildDigestHtml(user, alertsByType) {
|
||||||
|
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||||||
|
|
||||||
|
let alertsHtml = '';
|
||||||
|
|
||||||
|
// Alertes critiques
|
||||||
|
if (alertsByType.critical.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||||||
|
🔴 Alertes critiques (${alertsByType.critical.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertes warning
|
||||||
|
if (alertsByType.warning.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||||||
|
⚠️ Avertissements (${alertsByType.warning.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertes info
|
||||||
|
if (alertsByType.info.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||||||
|
ℹ️ Informations (${alertsByType.info.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
|
||||||
|
Bonjour ${user.firstName},
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
|
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
|
||||||
|
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${alertsHtml}
|
||||||
|
|
||||||
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
|
||||||
|
<a href="https://app.em2event.fr/#/alerts"
|
||||||
|
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Voir toutes les alertes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pied de page -->
|
||||||
|
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
|
||||||
|
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
|
||||||
|
Gérer mes préférences de notification
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un item d'alerte pour l'email
|
||||||
|
*/
|
||||||
|
function formatAlertItem(alert) {
|
||||||
|
const date = alert.createdAt?.toDate ?
|
||||||
|
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) :
|
||||||
|
'Date inconnue';
|
||||||
|
|
||||||
|
// Type d'alerte en français
|
||||||
|
const typeLabels = {
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'DAMAGED': 'Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'Écart de quantité',
|
||||||
|
'EVENT_CREATED': 'Événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabel = typeLabels[alert.type] || alert.type;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||||
|
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
|
||||||
|
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
${alert.message || 'Aucun message'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la couleur selon la sévérité
|
||||||
|
*/
|
||||||
|
function getSeverityColor(severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'CRITICAL': return '#dc2626';
|
||||||
|
case 'WARNING': return '#f59e0b';
|
||||||
|
case 'INFO': return '#3b82f6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendDailyDigest };
|
||||||
|
|
||||||
107
em2rp/functions/templates/alert-digest.html
Normal file
107
em2rp/functions/templates/alert-digest.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
<!-- En-tête du digest -->
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
|
||||||
|
📬 Votre résumé quotidien
|
||||||
|
</h2>
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 14px;">
|
||||||
|
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message d'introduction -->
|
||||||
|
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
|
||||||
|
Bonjour <strong>{{userName}}</strong>,<br>
|
||||||
|
Voici le récapitulatif de vos alertes des dernières 24 heures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Liste des alertes -->
|
||||||
|
{{#each alerts}}
|
||||||
|
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
|
||||||
|
<!-- Badge type -->
|
||||||
|
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
|
||||||
|
{{this.typeLabel}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre de l'alerte -->
|
||||||
|
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
|
||||||
|
{{this.title}}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
{{this.message}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Contexte -->
|
||||||
|
{{#if this.context}}
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 13px;">
|
||||||
|
<strong>Contexte :</strong> {{this.context}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
|
||||||
|
🕐 {{this.timestamp}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<!-- Aucune alerte -->
|
||||||
|
{{#unless alerts}}
|
||||||
|
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="color: #166534; margin: 0; font-size: 16px;">
|
||||||
|
✅ <strong>Aucune alerte aujourd'hui</strong><br>
|
||||||
|
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
|
<!-- Bouton d'action principal -->
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 6px; background: #3B82F6;">
|
||||||
|
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||||
|
Voir toutes mes alertes
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistiques -->
|
||||||
|
{{#if stats}}
|
||||||
|
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
|
||||||
|
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
|
||||||
|
📊 Vos statistiques
|
||||||
|
</h3>
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||||
|
<strong>Alertes non lues :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||||
|
{{stats.unreadCount}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||||
|
<strong>Événements en cours :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||||
|
{{stats.activeEvents}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Note de bas de page -->
|
||||||
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||||
|
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
81
em2rp/functions/templates/alert-individual.html
Normal file
81
em2rp/functions/templates/alert-individual.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
<!-- Badge de sévérité -->
|
||||||
|
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
|
||||||
|
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
|
||||||
|
{{alertTitle}}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
|
||||||
|
{{alertMessage}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Détails de l'alerte -->
|
||||||
|
{{#if alertDetails}}
|
||||||
|
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Détails :</strong><br>
|
||||||
|
{{alertDetails}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Informations contextuelles -->
|
||||||
|
{{#if eventName}}
|
||||||
|
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Événement :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{eventName}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{#if eventDate}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Date :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{eventDate}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
{{#if equipmentName}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Équipement :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{equipmentName}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
</table>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Bouton d'action -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
|
||||||
|
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||||
|
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note de bas de page -->
|
||||||
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||||
|
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
65
em2rp/functions/templates/base-template.html
Normal file
65
em2rp/functions/templates/base-template.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||||
|
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.container { width: 100% !important; }
|
||||||
|
.content { padding: 20px !important; }
|
||||||
|
.button { width: 100% !important; display: block !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 0;">
|
||||||
|
<!-- Container -->
|
||||||
|
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
|
||||||
|
EM2 Events
|
||||||
|
</h1>
|
||||||
|
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
|
||||||
|
Gestion d'événements professionnelle
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content" style="padding: 40px 30px;">
|
||||||
|
{{{content}}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
|
||||||
|
Cet email a été envoyé automatiquement par EM2 Events
|
||||||
|
</p>
|
||||||
|
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
|
||||||
|
© {{year}} EM2 Events. Tous droits réservés.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
165
em2rp/functions/utils/auth.js
Normal file
165
em2rp/functions/utils/auth.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Utilitaires d'authentification et d'autorisation
|
||||||
|
*/
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le token Firebase et retourne l'utilisateur
|
||||||
|
*/
|
||||||
|
async function authenticateUser(req) {
|
||||||
|
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
|
||||||
|
throw new Error('Unauthorized: No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = req.headers.authorization.split('Bearer ')[1];
|
||||||
|
try {
|
||||||
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
|
return decodedToken;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error verifying Firebase ID token:", e);
|
||||||
|
throw new Error('Unauthorized: Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les données utilisateur depuis Firestore
|
||||||
|
*/
|
||||||
|
async function getUserData(uid) {
|
||||||
|
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { uid, ...userDoc.data() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les permissions d'un rôle
|
||||||
|
*/
|
||||||
|
async function getRolePermissions(roleRef) {
|
||||||
|
if (!roleRef) return [];
|
||||||
|
|
||||||
|
let roleId;
|
||||||
|
if (typeof roleRef === 'string') {
|
||||||
|
roleId = roleRef;
|
||||||
|
} else if (roleRef.id) {
|
||||||
|
roleId = roleRef.id;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
|
||||||
|
if (!roleDoc.exists) return [];
|
||||||
|
|
||||||
|
return roleDoc.data().permissions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur a une permission spécifique
|
||||||
|
*/
|
||||||
|
async function hasPermission(uid, requiredPermission) {
|
||||||
|
const userData = await getUserData(uid);
|
||||||
|
if (!userData) return false;
|
||||||
|
|
||||||
|
const permissions = await getRolePermissions(userData.role);
|
||||||
|
return permissions.includes(requiredPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur est admin
|
||||||
|
*/
|
||||||
|
async function isAdmin(uid) {
|
||||||
|
const userData = await getUserData(uid);
|
||||||
|
if (!userData) return false;
|
||||||
|
|
||||||
|
let roleId;
|
||||||
|
const roleField = userData.role;
|
||||||
|
if (typeof roleField === 'string') {
|
||||||
|
roleId = roleField;
|
||||||
|
} else if (roleField && roleField.id) {
|
||||||
|
roleId = roleField.id;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roleId === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur est assigné à un événement
|
||||||
|
*/
|
||||||
|
async function isAssignedToEvent(uid, eventId) {
|
||||||
|
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
|
||||||
|
if (!eventDoc.exists) return false;
|
||||||
|
|
||||||
|
const eventData = eventDoc.data();
|
||||||
|
const workforce = eventData.workforce || [];
|
||||||
|
|
||||||
|
// workforce contient des références DocumentReference
|
||||||
|
return workforce.some(ref => {
|
||||||
|
if (typeof ref === 'string') return ref === uid;
|
||||||
|
if (ref && ref.id) return ref.id === uid;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware d'authentification pour les Cloud Functions HTTP
|
||||||
|
*/
|
||||||
|
async function authMiddleware(req, res, next) {
|
||||||
|
try {
|
||||||
|
const decodedToken = await authenticateUser(req);
|
||||||
|
req.user = decodedToken;
|
||||||
|
req.uid = decodedToken.uid;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de vérification de permission
|
||||||
|
*/
|
||||||
|
function requirePermission(permission) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hasAccess = await hasPermission(req.uid, permission);
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware admin uniquement
|
||||||
|
*/
|
||||||
|
async function requireAdmin(req, res, next) {
|
||||||
|
try {
|
||||||
|
const adminAccess = await isAdmin(req.uid);
|
||||||
|
if (!adminAccess) {
|
||||||
|
res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authenticateUser,
|
||||||
|
getUserData,
|
||||||
|
getRolePermissions,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin,
|
||||||
|
isAssignedToEvent,
|
||||||
|
authMiddleware,
|
||||||
|
requirePermission,
|
||||||
|
requireAdmin,
|
||||||
|
};
|
||||||
|
|
||||||
39
em2rp/functions/utils/emailConfig.js
Normal file
39
em2rp/functions/utils/emailConfig.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Configuration SMTP pour l'envoi d'emails
|
||||||
|
* Les credentials sont stockés dans les variables d'environnement
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuration SMTP depuis les variables d'environnement
|
||||||
|
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||||
|
const getSmtpConfig = () => {
|
||||||
|
return {
|
||||||
|
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||||
|
secure: true, // true pour port 465, false pour autres ports
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
||||||
|
pass: process.env.SMTP_PASS || '',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
// Ne pas échouer sur certificats invalides
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration email par défaut
|
||||||
|
const EMAIL_CONFIG = {
|
||||||
|
from: {
|
||||||
|
name: 'EM2 Events',
|
||||||
|
address: 'notify@em2events.fr',
|
||||||
|
},
|
||||||
|
replyTo: 'contact@em2events.fr',
|
||||||
|
// URL de l'application pour les liens
|
||||||
|
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSmtpConfig,
|
||||||
|
EMAIL_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
177
em2rp/functions/utils/emailTemplates.js
Normal file
177
em2rp/functions/utils/emailTemplates.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const admin = require('firebase-admin');
|
||||||
|
const handlebars = require('handlebars');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const {EMAIL_CONFIG} = require('./emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||||
|
*/
|
||||||
|
function checkAlertPreference(alertType, preferences) {
|
||||||
|
const typeMapping = {
|
||||||
|
'EVENT_CREATED': 'eventsNotifications',
|
||||||
|
'EVENT_MODIFIED': 'eventsNotifications',
|
||||||
|
'EVENT_CANCELLED': 'eventsNotifications',
|
||||||
|
'LOST': 'equipmentNotifications',
|
||||||
|
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||||
|
'DAMAGED': 'equipmentNotifications',
|
||||||
|
'QUANTITY_MISMATCH': 'equipmentNotifications',
|
||||||
|
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||||
|
'STOCK_LOW': 'stockNotifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefKey = typeMapping[alertType];
|
||||||
|
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare les données pour le template
|
||||||
|
*/
|
||||||
|
async function prepareTemplateData(alert, user) {
|
||||||
|
const data = {
|
||||||
|
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||||
|
'Utilisateur',
|
||||||
|
alertTitle: getAlertTitle(alert.type),
|
||||||
|
alertMessage: alert.message,
|
||||||
|
isCritical: alert.severity === 'CRITICAL',
|
||||||
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||||
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter des détails selon le type d'alerte
|
||||||
|
if (alert.eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await admin.firestore()
|
||||||
|
.collection('events')
|
||||||
|
.doc(alert.eventId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
data.eventName = event.Name || event.name || 'Événement';
|
||||||
|
if (event.StartDateTime || event.startDate) {
|
||||||
|
const dateField = event.StartDateTime || event.startDate;
|
||||||
|
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||||
|
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignorer silencieusement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert.equipmentId) {
|
||||||
|
try {
|
||||||
|
const eqDoc = await admin.firestore()
|
||||||
|
.collection('equipments')
|
||||||
|
.doc(alert.equipmentId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
data.equipmentName = eqDoc.data().name;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignorer silencieusement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre de l'email selon le type d'alerte
|
||||||
|
*/
|
||||||
|
function getEmailSubject(alert) {
|
||||||
|
const subjects = {
|
||||||
|
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||||
|
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||||
|
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||||
|
'DAMAGED': '⚠️ Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
||||||
|
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||||
|
'STOCK_LOW': '📦 Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre pour le corps de l'email
|
||||||
|
*/
|
||||||
|
function getAlertTitle(type) {
|
||||||
|
const titles = {
|
||||||
|
'EVENT_CREATED': 'Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'EVENT_CANCELLED': 'Événement annulé',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'DAMAGED': 'Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
||||||
|
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||||
|
'STOCK_LOW': 'Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[type] || 'Nouvelle alerte';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend un template HTML avec Handlebars
|
||||||
|
*/
|
||||||
|
async function renderTemplate(templateName, data) {
|
||||||
|
try {
|
||||||
|
// Lire le template de base
|
||||||
|
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
|
||||||
|
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||||
|
|
||||||
|
// Lire le template de contenu
|
||||||
|
const contentPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'templates',
|
||||||
|
`${templateName}.html`,
|
||||||
|
);
|
||||||
|
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||||
|
|
||||||
|
// Compiler les templates
|
||||||
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
|
const compileBase = handlebars.compile(baseTemplate);
|
||||||
|
|
||||||
|
// Rendre le contenu
|
||||||
|
const renderedContent = compileContent(data);
|
||||||
|
|
||||||
|
// Rendre le template de base avec le contenu
|
||||||
|
return compileBase({
|
||||||
|
...data,
|
||||||
|
content: renderedContent,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback vers un template simple
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${data.alertTitle}</h2>
|
||||||
|
<p>${data.alertMessage}</p>
|
||||||
|
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkAlertPreference,
|
||||||
|
prepareTemplateData,
|
||||||
|
getEmailSubject,
|
||||||
|
getAlertTitle,
|
||||||
|
renderTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
191
em2rp/functions/utils/helpers.js
Normal file
191
em2rp/functions/utils/helpers.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Helpers pour la manipulation de données Firestore
|
||||||
|
*/
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
||||||
|
*/
|
||||||
|
function serializeTimestamps(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
// Éviter la récursion sur les types Firestore spéciaux
|
||||||
|
if (data._firestore || data._path || data._converter) {
|
||||||
|
// C'est un objet Firestore interne, ne pas le traiter
|
||||||
|
if (data.id && data.path) {
|
||||||
|
// C'est une DocumentReference
|
||||||
|
return data.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const key in result) {
|
||||||
|
const value = result[key];
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer les Timestamps Firestore
|
||||||
|
if (value.toDate && typeof value.toDate === 'function') {
|
||||||
|
result[key] = value.toDate().toISOString();
|
||||||
|
}
|
||||||
|
// Gérer les DocumentReference
|
||||||
|
else if (value.path && value.id && typeof value.path === 'string') {
|
||||||
|
result[key] = value.path;
|
||||||
|
}
|
||||||
|
// Gérer les GeoPoint
|
||||||
|
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
||||||
|
result[key] = {
|
||||||
|
latitude: value.latitude,
|
||||||
|
longitude: value.longitude
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Gérer les tableaux
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
result[key] = value.map(item => {
|
||||||
|
if (!item || typeof item !== 'object') return item;
|
||||||
|
|
||||||
|
// DocumentReference dans un tableau
|
||||||
|
if (item.path && item.id) {
|
||||||
|
return item.path;
|
||||||
|
}
|
||||||
|
// Timestamp dans un tableau
|
||||||
|
if (item.toDate && typeof item.toDate === 'function') {
|
||||||
|
return item.toDate().toISOString();
|
||||||
|
}
|
||||||
|
// Objet normal
|
||||||
|
return serializeTimestamps(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
||||||
|
else if (typeof value === 'object' && !value._firestore && !value._path) {
|
||||||
|
result[key] = serializeTimestamps(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les ISO strings en Timestamps Firestore
|
||||||
|
*/
|
||||||
|
function deserializeTimestamps(data, timestampFields = []) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const field of timestampFields) {
|
||||||
|
if (result[field] && typeof result[field] === 'string') {
|
||||||
|
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les références DocumentReference en IDs
|
||||||
|
*/
|
||||||
|
function serializeReferences(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const key in result) {
|
||||||
|
if (result[key] && result[key].path && typeof result[key].path === 'string') {
|
||||||
|
// C'est une DocumentReference
|
||||||
|
result[key] = result[key].id;
|
||||||
|
} else if (Array.isArray(result[key])) {
|
||||||
|
result[key] = result[key].map(item => {
|
||||||
|
if (item && item.path && typeof item.path === 'string') {
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque les champs sensibles selon les permissions
|
||||||
|
*/
|
||||||
|
function maskSensitiveFields(data, canViewSensitive) {
|
||||||
|
if (canViewSensitive) return data;
|
||||||
|
|
||||||
|
const masked = { ...data };
|
||||||
|
|
||||||
|
// Masquer les prix si pas de permission manage_equipment
|
||||||
|
delete masked.purchasePrice;
|
||||||
|
delete masked.rentalPrice;
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination helper
|
||||||
|
*/
|
||||||
|
function paginate(query, limit = 50, startAfter = null) {
|
||||||
|
let paginatedQuery = query.limit(limit);
|
||||||
|
|
||||||
|
if (startAfter) {
|
||||||
|
paginatedQuery = paginatedQuery.startAfter(startAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginatedQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre les événements annulés
|
||||||
|
*/
|
||||||
|
function filterCancelledEvents(events) {
|
||||||
|
return events.filter(event => event.status !== 'CANCELLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||||
|
* @param {Object} data - Données de l'événement
|
||||||
|
* @returns {Object} - Données avec DocumentReference
|
||||||
|
*/
|
||||||
|
function convertIdsToReferences(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
// Convertir EventType (ID → DocumentReference)
|
||||||
|
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
||||||
|
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir customer (ID → DocumentReference)
|
||||||
|
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
||||||
|
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir workforce (IDs → DocumentReference)
|
||||||
|
if (Array.isArray(result.workforce)) {
|
||||||
|
result.workforce = result.workforce.map(item => {
|
||||||
|
if (typeof item === 'string' && !item.includes('/')) {
|
||||||
|
return admin.firestore().collection('users').doc(item);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
serializeTimestamps,
|
||||||
|
deserializeTimestamps,
|
||||||
|
serializeReferences,
|
||||||
|
maskSensitiveFields,
|
||||||
|
paginate,
|
||||||
|
filterCancelledEvents,
|
||||||
|
convertIdsToReferences,
|
||||||
|
};
|
||||||
|
|
||||||
19
em2rp/lib/config/api_config.dart
Normal file
19
em2rp/lib/config/api_config.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/// Configuration de l'API backend
|
||||||
|
class ApiConfig {
|
||||||
|
// Mode développement : utilise les émulateurs locaux
|
||||||
|
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
||||||
|
|
||||||
|
// URL de base pour les Cloud Functions
|
||||||
|
static const String productionUrl = 'https://europe-west9-em2rp-951dc.cloudfunctions.net';
|
||||||
|
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/europe-west9';
|
||||||
|
|
||||||
|
/// Retourne l'URL de base selon l'environnement
|
||||||
|
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
||||||
|
|
||||||
|
/// Configuration du timeout
|
||||||
|
static const Duration requestTimeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Nombre de tentatives en cas d'échec
|
||||||
|
static const int maxRetries = 3;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '0.3.5';
|
static const String version = '1.1.14';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|
||||||
|
|
||||||
/// Retourne la version avec un préfixe personnalisé
|
/// Retourne la version avec un préfixe personnalisé
|
||||||
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import 'package:em2rp/models/event_model.dart';
|
|||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/services/event_form_service.dart';
|
import 'package:em2rp/services/event_form_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
|
||||||
|
|
||||||
class EventFormController extends ChangeNotifier {
|
class EventFormController extends ChangeNotifier {
|
||||||
// Controllers
|
// Controllers
|
||||||
@@ -89,7 +90,20 @@ class EventFormController extends ChangeNotifier {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (existingEvent != null) {
|
if (existingEvent != null) {
|
||||||
_populateFromEvent(existingEvent);
|
// 🔧 FIX: Recharger l'événement avec tous les détails (équipements + containers avec enfants)
|
||||||
|
try {
|
||||||
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
final result = await dataService.getEventWithDetails(existingEvent.id);
|
||||||
|
final eventData = result['event'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Reconstruire l'événement avec les données complètes
|
||||||
|
final completeEvent = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
_populateFromEvent(completeEvent);
|
||||||
|
} catch (e) {
|
||||||
|
// Si erreur, utiliser l'événement existant (fallback)
|
||||||
|
print('[EventFormController] Error loading event with details, using existing: $e');
|
||||||
|
_populateFromEvent(existingEvent);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_selectedStatus = EventStatus.waitingForApproval;
|
_selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
|
||||||
@@ -125,7 +139,14 @@ class EventFormController extends ChangeNotifier {
|
|||||||
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||||
_assignedContainers = List<String>.from(event.assignedContainers);
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||||
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||||
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
|
|
||||||
|
// Gérer workforce qui peut contenir String ou DocumentReference
|
||||||
|
_selectedUserIds = event.workforce.map((ref) {
|
||||||
|
if (ref is String) return ref;
|
||||||
|
if (ref is DocumentReference) return ref.id;
|
||||||
|
return '';
|
||||||
|
}).where((id) => id.isNotEmpty).toList();
|
||||||
|
|
||||||
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
||||||
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
||||||
_selectedStatus = event.status;
|
_selectedStatus = event.status;
|
||||||
@@ -183,15 +204,15 @@ class EventFormController extends ChangeNotifier {
|
|||||||
if (newTypeId != null) {
|
if (newTypeId != null) {
|
||||||
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||||
|
|
||||||
// Utiliser le prix par défaut du type d'événement
|
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||||
final defaultPrice = selectedType.defaultPrice;
|
final defaultPriceTTC = selectedType.defaultPrice;
|
||||||
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||||
final oldDefaultPrice = oldEventType?.defaultPrice;
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||||
|
|
||||||
// Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut
|
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||||
if (basePriceController.text.isEmpty ||
|
if (basePriceController.text.isEmpty ||
|
||||||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||||
basePriceController.text = defaultPrice.toStringAsFixed(2);
|
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||||
@@ -285,7 +306,7 @@ class EventFormController extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final eventTypeRef = _selectedEventTypeId != null
|
final eventTypeRef = _selectedEventTypeId != null
|
||||||
? FirebaseFirestore.instance.collection('eventTypes').doc(_selectedEventTypeId)
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (existingEvent != null) {
|
if (existingEvent != null) {
|
||||||
@@ -325,9 +346,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: existingEvent.customerId,
|
customerId: existingEvent.customerId,
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: existingEvent.latitude,
|
latitude: existingEvent.latitude,
|
||||||
longitude: existingEvent.longitude,
|
longitude: existingEvent.longitude,
|
||||||
documents: finalDocuments,
|
documents: finalDocuments,
|
||||||
@@ -344,15 +364,9 @@ class EventFormController extends ChangeNotifier {
|
|||||||
|
|
||||||
await EventFormService.updateEvent(updatedEvent);
|
await EventFormService.updateEvent(updatedEvent);
|
||||||
|
|
||||||
// Recharger les événements après modification
|
// Mettre à jour l'événement dans le cache (au lieu de tout recharger)
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localUserProvider.uid;
|
await eventProvider.updateEvent(updatedEvent);
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
_success = "Événement modifié avec succès !";
|
_success = "Événement modifié avec succès !";
|
||||||
} else {
|
} else {
|
||||||
@@ -370,9 +384,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: '',
|
customerId: '',
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: 0.0,
|
latitude: 0.0,
|
||||||
longitude: 0.0,
|
longitude: 0.0,
|
||||||
documents: _uploadedFiles,
|
documents: _uploadedFiles,
|
||||||
@@ -386,19 +399,24 @@ class EventFormController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final eventId = await EventFormService.createEvent(newEvent);
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
|
||||||
await EventFormService.updateEventDocuments(eventId, newFiles);
|
|
||||||
|
|
||||||
// Reload events
|
// Créer l'événement avec l'ID retourné
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
EventModel createdEvent = newEvent.copyWith(id: eventId);
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
||||||
final userId = localUserProvider.uid;
|
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
if (_uploadedFiles.isNotEmpty) {
|
||||||
|
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
||||||
|
if (newFiles.isNotEmpty) {
|
||||||
|
await EventFormService.updateEventDocuments(eventId, newFiles);
|
||||||
|
// Mettre à jour l'événement avec les nouvelles URLs
|
||||||
|
createdEvent = createdEvent.copyWith(documents: newFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajouter l'événement au cache
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
await eventProvider.addEvent(createdEvent);
|
||||||
|
|
||||||
_success = "Événement créé avec succès !";
|
_success = "Événement créé avec succès !";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,18 +440,9 @@ class EventFormController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Supprimer l'événement de Firestore
|
// Supprimer l'événement via le provider (qui appelle l'API et met à jour le cache)
|
||||||
await FirebaseFirestore.instance.collection('events').doc(eventId).delete();
|
|
||||||
|
|
||||||
// Recharger la liste des événements
|
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localUserProvider.uid;
|
await eventProvider.deleteEvent(eventId);
|
||||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
_success = "Événement supprimé avec succès !";
|
_success = "Événement supprimé avec succès !";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
import 'package:em2rp/providers/alert_provider.dart';
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
|
import 'package:em2rp/views/alerts_page.dart';
|
||||||
import 'package:em2rp/views/calendar_page.dart';
|
import 'package:em2rp/views/calendar_page.dart';
|
||||||
import 'package:em2rp/views/login_page.dart';
|
import 'package:em2rp/views/login_page.dart';
|
||||||
import 'package:em2rp/views/equipment_management_page.dart';
|
import 'package:em2rp/views/equipment_management_page.dart';
|
||||||
import 'package:em2rp/views/container_management_page.dart';
|
import 'package:em2rp/views/container_management_page.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_management_page.dart';
|
||||||
import 'package:em2rp/views/container_form_page.dart';
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
import 'package:em2rp/views/container_detail_page.dart';
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -22,45 +27,63 @@ import 'views/my_account_page.dart';
|
|||||||
import 'views/user_management_page.dart';
|
import 'views/user_management_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'services/user_service.dart';
|
|
||||||
import 'views/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
|
import 'services/update_service.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
|
import 'config/api_config.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Configuration des émulateurs en mode développement
|
||||||
|
if (ApiConfig.isDevelopment) {
|
||||||
|
print('🔧 Mode développement activé - Utilisation des émulateurs');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Auth
|
||||||
|
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||||
|
print('✓ Auth émulateur configuré: localhost:9199');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Firestore
|
||||||
|
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||||
|
print('✓ Firestore émulateur configuré: localhost:8088');
|
||||||
|
}
|
||||||
|
|
||||||
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
// Injection du service UserService
|
|
||||||
Provider<UserService>(create: (_) => UserService()),
|
|
||||||
|
|
||||||
// LocalUserProvider pour la gestion de l'authentification
|
// LocalUserProvider pour la gestion de l'authentification
|
||||||
ChangeNotifierProvider<LocalUserProvider>(
|
ChangeNotifierProvider<LocalUserProvider>(
|
||||||
create: (context) => LocalUserProvider()),
|
create: (context) => LocalUserProvider()),
|
||||||
|
|
||||||
// Injection des Providers en utilisant UserService
|
// UsersProvider migré vers l'API
|
||||||
ChangeNotifierProvider<UsersProvider>(
|
ChangeNotifierProvider<UsersProvider>(
|
||||||
create: (context) => UsersProvider(context.read<UserService>()),
|
create: (context) => UsersProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// EventProvider pour la gestion des événements
|
// EventProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EventProvider>(
|
ChangeNotifierProvider<EventProvider>(
|
||||||
create: (context) => EventProvider(),
|
create: (context) => EventProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Providers pour la gestion du matériel
|
// EquipmentProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EquipmentProvider>(
|
ChangeNotifierProvider<EquipmentProvider>(
|
||||||
create: (context) => EquipmentProvider(),
|
create: (context) => EquipmentProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ContainerProvider migré vers l'API
|
||||||
ChangeNotifierProvider<ContainerProvider>(
|
ChangeNotifierProvider<ContainerProvider>(
|
||||||
create: (context) => ContainerProvider(),
|
create: (context) => ContainerProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// MaintenanceProvider migré vers l'API
|
||||||
ChangeNotifierProvider<MaintenanceProvider>(
|
ChangeNotifierProvider<MaintenanceProvider>(
|
||||||
create: (context) => MaintenanceProvider(),
|
create: (context) => MaintenanceProvider(),
|
||||||
),
|
),
|
||||||
@@ -79,24 +102,24 @@ class MyApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'EM2 ERP',
|
title: 'EM2 Hub',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.red,
|
primarySwatch: Colors.red,
|
||||||
primaryColor: AppColors.noir,
|
primaryColor: AppColors.noir,
|
||||||
colorScheme:
|
colorScheme:
|
||||||
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
bodyMedium: TextStyle(color: AppColors.noir),
|
bodyMedium: TextStyle(color: AppColors.noir),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: AppColors.noir),
|
||||||
),
|
),
|
||||||
inputDecorationTheme: const InputDecorationTheme(
|
enabledBorder: OutlineInputBorder(
|
||||||
focusedBorder: OutlineInputBorder(
|
borderSide: BorderSide(color: AppColors.gris),
|
||||||
borderSide: BorderSide(color: AppColors.noir),
|
),
|
||||||
),
|
labelStyle: TextStyle(color: AppColors.noir),
|
||||||
enabledBorder: OutlineInputBorder(
|
hintStyle: TextStyle(color: AppColors.gris),
|
||||||
borderSide: BorderSide(color: AppColors.gris),
|
|
||||||
),
|
|
||||||
labelStyle: TextStyle(color: AppColors.noir),
|
|
||||||
hintStyle: TextStyle(color: AppColors.gris),
|
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -114,9 +137,11 @@ class MyApp extends StatelessWidget {
|
|||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
home: const AutoLoginWrapper(),
|
initialRoute: '/',
|
||||||
routes: {
|
routes: {
|
||||||
|
'/': (context) => const AutoLoginWrapper(),
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
|
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
||||||
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
||||||
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
||||||
'/user_management': (context) => const AuthGuard(
|
'/user_management': (context) => const AuthGuard(
|
||||||
@@ -135,6 +160,9 @@ class MyApp extends StatelessWidget {
|
|||||||
'/container_management': (context) => const AuthGuard(
|
'/container_management': (context) => const AuthGuard(
|
||||||
requiredPermission: "view_equipment",
|
requiredPermission: "view_equipment",
|
||||||
child: ContainerManagementPage()),
|
child: ContainerManagementPage()),
|
||||||
|
'/maintenance_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "manage_maintenances",
|
||||||
|
child: MaintenanceManagementPage()),
|
||||||
'/container_form': (context) {
|
'/container_form': (context) {
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
return AuthGuard(
|
return AuthGuard(
|
||||||
@@ -152,9 +180,12 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
'/event_preparation': (context) {
|
'/event_preparation': (context) {
|
||||||
final eventId = ModalRoute.of(context)!.settings.arguments as String;
|
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||||
|
final event = args['event'] as EventModel;
|
||||||
return AuthGuard(
|
return AuthGuard(
|
||||||
child: EventPreparationPage(eventId: eventId),
|
child: EventPreparationPage(
|
||||||
|
initialEvent: event,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -173,31 +204,87 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_autoLogin();
|
// Attendre la fin du premier build avant de naviguer
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_autoLogin();
|
||||||
|
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
|
||||||
|
_checkForUpdateDelayed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie les mises à jour après un délai
|
||||||
|
Future<void> _checkForUpdateDelayed() async {
|
||||||
|
try {
|
||||||
|
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateService.checkForUpdate();
|
||||||
|
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
// Attendre encore un peu pour être sûr que le bon contexte est disponible
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !updateInfo.forceUpdate,
|
||||||
|
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[AutoLoginWrapper] Error checking for update: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _autoLogin() async {
|
Future<void> _autoLogin() async {
|
||||||
|
PerformanceMonitor.start('App.autoLogin');
|
||||||
try {
|
try {
|
||||||
final localAuthProvider =
|
final localAuthProvider =
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
|
||||||
// Vérifier si l'utilisateur est déjà connecté
|
// Vérifier si l'utilisateur est déjà connecté
|
||||||
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
||||||
|
PerformanceMonitor.start('App.signIn');
|
||||||
// Connexion automatique en mode développement
|
// Connexion automatique en mode développement
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
Env.devAdminEmail,
|
Env.devAdminEmail,
|
||||||
Env.devAdminPassword,
|
Env.devAdminPassword,
|
||||||
);
|
);
|
||||||
|
PerformanceMonitor.end('App.signIn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les données utilisateur
|
|
||||||
await localAuthProvider.loadUserData();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||||
|
// En Flutter Web, on peut vérifier window.location.hash
|
||||||
|
final currentUri = Uri.base;
|
||||||
|
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
||||||
|
|
||||||
|
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||||
|
|
||||||
|
// Navigation immédiate sans attendre le chargement des données
|
||||||
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
|
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||||
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
|
} else {
|
||||||
|
// Route par défaut : calendrier
|
||||||
|
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||||
|
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMonitor.end('App.autoLogin');
|
||||||
|
PerformanceMonitor.printSummary();
|
||||||
|
|
||||||
|
// Charger les données utilisateur en arrière-plan
|
||||||
|
localAuthProvider.loadUserData().catchError((e) {
|
||||||
|
print('Error loading user data: $e');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Auto login failed: $e');
|
print('Auto login failed: $e');
|
||||||
|
PerformanceMonitor.end('App.autoLogin');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacementNamed('/login');
|
Navigator.of(context).pushReplacementNamed('/login');
|
||||||
}
|
}
|
||||||
@@ -206,9 +293,41 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: CircularProgressIndicator(),
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Logo de l'application
|
||||||
|
Image.asset(
|
||||||
|
'assets/logos/RectangleLogoBlack.png',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 80,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Chargement...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Type d'alerte
|
||||||
enum AlertType {
|
enum AlertType {
|
||||||
lowStock, // Stock faible
|
lowStock, // Stock faible
|
||||||
maintenanceDue, // Maintenance à venir
|
maintenanceDue, // Maintenance à venir
|
||||||
conflict // Conflit disponibilité
|
conflict, // Conflit disponibilité
|
||||||
|
lost, // Équipement perdu
|
||||||
|
eventCreated, // Événement créé
|
||||||
|
eventModified, // Événement modifié
|
||||||
|
eventCancelled, // Événement annulé
|
||||||
|
eventAssigned, // Assigné à un événement
|
||||||
|
maintenanceReminder, // Rappel maintenance périodique
|
||||||
|
equipmentMissing, // Équipement manquant à une étape
|
||||||
|
quantityMismatch, // Quantité incorrecte
|
||||||
|
damaged, // Équipement endommagé
|
||||||
|
workforceAdded, // Ajouté à la workforce d'un événement
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gravité de l'alerte
|
||||||
|
enum AlertSeverity {
|
||||||
|
info, // Information (bleu)
|
||||||
|
warning, // Avertissement (orange)
|
||||||
|
critical, // Critique (rouge)
|
||||||
}
|
}
|
||||||
|
|
||||||
String alertTypeToString(AlertType type) {
|
String alertTypeToString(AlertType type) {
|
||||||
@@ -14,6 +32,26 @@ String alertTypeToString(AlertType type) {
|
|||||||
return 'MAINTENANCE_DUE';
|
return 'MAINTENANCE_DUE';
|
||||||
case AlertType.conflict:
|
case AlertType.conflict:
|
||||||
return 'CONFLICT';
|
return 'CONFLICT';
|
||||||
|
case AlertType.lost:
|
||||||
|
return 'LOST';
|
||||||
|
case AlertType.eventCreated:
|
||||||
|
return 'EVENT_CREATED';
|
||||||
|
case AlertType.eventModified:
|
||||||
|
return 'EVENT_MODIFIED';
|
||||||
|
case AlertType.eventCancelled:
|
||||||
|
return 'EVENT_CANCELLED';
|
||||||
|
case AlertType.eventAssigned:
|
||||||
|
return 'EVENT_ASSIGNED';
|
||||||
|
case AlertType.maintenanceReminder:
|
||||||
|
return 'MAINTENANCE_REMINDER';
|
||||||
|
case AlertType.equipmentMissing:
|
||||||
|
return 'EQUIPMENT_MISSING';
|
||||||
|
case AlertType.quantityMismatch:
|
||||||
|
return 'QUANTITY_MISMATCH';
|
||||||
|
case AlertType.damaged:
|
||||||
|
return 'DAMAGED';
|
||||||
|
case AlertType.workforceAdded:
|
||||||
|
return 'WORKFORCE_ADDED';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,65 +63,211 @@ AlertType alertTypeFromString(String? type) {
|
|||||||
return AlertType.maintenanceDue;
|
return AlertType.maintenanceDue;
|
||||||
case 'CONFLICT':
|
case 'CONFLICT':
|
||||||
return AlertType.conflict;
|
return AlertType.conflict;
|
||||||
|
case 'LOST':
|
||||||
|
return AlertType.lost;
|
||||||
|
case 'EVENT_CREATED':
|
||||||
|
return AlertType.eventCreated;
|
||||||
|
case 'EVENT_MODIFIED':
|
||||||
|
return AlertType.eventModified;
|
||||||
|
case 'EVENT_CANCELLED':
|
||||||
|
return AlertType.eventCancelled;
|
||||||
|
case 'EVENT_ASSIGNED':
|
||||||
|
return AlertType.eventAssigned;
|
||||||
|
case 'MAINTENANCE_REMINDER':
|
||||||
|
return AlertType.maintenanceReminder;
|
||||||
|
case 'EQUIPMENT_MISSING':
|
||||||
|
return AlertType.equipmentMissing;
|
||||||
|
case 'QUANTITY_MISMATCH':
|
||||||
|
return AlertType.quantityMismatch;
|
||||||
|
case 'DAMAGED':
|
||||||
|
return AlertType.damaged;
|
||||||
|
case 'WORKFORCE_ADDED':
|
||||||
|
return AlertType.workforceAdded;
|
||||||
default:
|
default:
|
||||||
return AlertType.conflict;
|
return AlertType.conflict;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String alertSeverityToString(AlertSeverity severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case AlertSeverity.info:
|
||||||
|
return 'INFO';
|
||||||
|
case AlertSeverity.warning:
|
||||||
|
return 'WARNING';
|
||||||
|
case AlertSeverity.critical:
|
||||||
|
return 'CRITICAL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertSeverity alertSeverityFromString(String? severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'INFO':
|
||||||
|
return AlertSeverity.info;
|
||||||
|
case 'WARNING':
|
||||||
|
return AlertSeverity.warning;
|
||||||
|
case 'CRITICAL':
|
||||||
|
return AlertSeverity.critical;
|
||||||
|
default:
|
||||||
|
return AlertSeverity.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AlertModel {
|
class AlertModel {
|
||||||
final String id; // ID généré automatiquement
|
final String id; // ID généré automatiquement
|
||||||
final AlertType type; // Type d'alerte
|
final AlertType type; // Type d'alerte
|
||||||
final String message; // Message de l'alerte
|
final AlertSeverity severity; // Gravité de l'alerte
|
||||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
final String message; // Message de l'alerte
|
||||||
final DateTime createdAt; // Date de création
|
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||||
final bool isRead; // Statut lu/non lu
|
final String? eventId; // ID de l'événement concerné (optionnel)
|
||||||
|
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||||
|
final String? createdByUserId; // Qui a déclenché l'alerte
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime? dueDate; // Date d'échéance (pour maintenance)
|
||||||
|
final String? actionUrl; // URL de redirection (deep link)
|
||||||
|
final bool isRead; // Statut lu/non lu
|
||||||
|
final bool isResolved; // Résolue ou non
|
||||||
|
final String? resolution; // Message de résolution
|
||||||
|
final DateTime? resolvedAt; // Date de résolution
|
||||||
|
final String? resolvedByUserId; // Qui a résolu
|
||||||
|
|
||||||
AlertModel({
|
AlertModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.type,
|
required this.type,
|
||||||
|
this.severity = AlertSeverity.info,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.assignedToUserIds = const [],
|
||||||
|
this.eventId,
|
||||||
this.equipmentId,
|
this.equipmentId,
|
||||||
|
this.createdByUserId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.dueDate,
|
||||||
|
this.actionUrl,
|
||||||
this.isRead = false,
|
this.isRead = false,
|
||||||
|
this.isResolved = false,
|
||||||
|
this.resolution,
|
||||||
|
this.resolvedAt,
|
||||||
|
this.resolvedByUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les assignedToUserIds (peut être List ou null)
|
||||||
|
List<String> parseUserIds(dynamic value) {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return AlertModel(
|
return AlertModel(
|
||||||
id: id,
|
id: id,
|
||||||
type: alertTypeFromString(map['type']),
|
type: alertTypeFromString(map['type']),
|
||||||
|
severity: alertSeverityFromString(map['severity']),
|
||||||
message: map['message'] ?? '',
|
message: map['message'] ?? '',
|
||||||
|
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||||
|
eventId: map['eventId'],
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
|
createdAt: _parseDate(map['createdAt']),
|
||||||
|
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||||
|
actionUrl: map['actionUrl'],
|
||||||
isRead: map['isRead'] ?? false,
|
isRead: map['isRead'] ?? false,
|
||||||
|
isResolved: map['isResolved'] ?? false,
|
||||||
|
resolution: map['resolution'],
|
||||||
|
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||||
|
resolvedByUserId: map['resolvedByUserId'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Factory depuis un document Firestore
|
||||||
|
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Document vide: ${doc.id}');
|
||||||
|
}
|
||||||
|
return AlertModel.fromMap(data, doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'type': alertTypeToString(type),
|
'type': alertTypeToString(type),
|
||||||
|
'severity': alertSeverityToString(severity),
|
||||||
'message': message,
|
'message': message,
|
||||||
'equipmentId': equipmentId,
|
'assignedToUserIds': assignedToUserIds,
|
||||||
|
if (eventId != null) 'eventId': eventId,
|
||||||
|
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||||
|
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||||
'createdAt': Timestamp.fromDate(createdAt),
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||||
|
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||||
'isRead': isRead,
|
'isRead': isRead,
|
||||||
|
'isResolved': isResolved,
|
||||||
|
if (resolution != null) 'resolution': resolution,
|
||||||
|
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||||
|
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertModel copyWith({
|
AlertModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
AlertType? type,
|
AlertType? type,
|
||||||
|
AlertSeverity? severity,
|
||||||
String? message,
|
String? message,
|
||||||
|
List<String>? assignedToUserIds,
|
||||||
|
String? eventId,
|
||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
|
String? createdByUserId,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? actionUrl,
|
||||||
bool? isRead,
|
bool? isRead,
|
||||||
|
bool? isResolved,
|
||||||
|
String? resolution,
|
||||||
|
DateTime? resolvedAt,
|
||||||
|
String? resolvedByUserId,
|
||||||
}) {
|
}) {
|
||||||
return AlertModel(
|
return AlertModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
severity: severity ?? this.severity,
|
||||||
message: message ?? this.message,
|
message: message ?? this.message,
|
||||||
|
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
actionUrl: actionUrl ?? this.actionUrl,
|
||||||
isRead: isRead ?? this.isRead,
|
isRead: isRead ?? this.isRead,
|
||||||
|
isResolved: isResolved ?? this.isResolved,
|
||||||
|
resolution: resolution ?? this.resolution,
|
||||||
|
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||||
|
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un événement
|
||||||
|
bool get isEventAlert =>
|
||||||
|
type == AlertType.eventCreated ||
|
||||||
|
type == AlertType.eventModified ||
|
||||||
|
type == AlertType.eventCancelled ||
|
||||||
|
type == AlertType.eventAssigned;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||||
|
bool get isMaintenanceAlert =>
|
||||||
|
type == AlertType.maintenanceDue ||
|
||||||
|
type == AlertType.maintenanceReminder;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||||
|
bool get isEquipmentAlert =>
|
||||||
|
type == AlertType.lost ||
|
||||||
|
type == AlertType.equipmentMissing ||
|
||||||
|
type == AlertType.lowStock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ class ContainerModel {
|
|||||||
|
|
||||||
/// Factory depuis Firestore
|
/// Factory depuis Firestore
|
||||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
@@ -262,8 +270,8 @@ class ContainerModel {
|
|||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
history: history,
|
history: history,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,8 +350,16 @@ class ContainerHistoryEntry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||||
|
// Helper pour parser la date
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
return ContainerHistoryEntry(
|
return ContainerHistoryEntry(
|
||||||
timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
timestamp: _parseDate(map['timestamp']),
|
||||||
action: map['action'] ?? '',
|
action: map['action'] ?? '',
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
previousValue: map['previousValue'],
|
previousValue: map['previousValue'],
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ enum EquipmentCategory {
|
|||||||
structure, // Structure
|
structure, // Structure
|
||||||
consumable, // Consommable
|
consumable, // Consommable
|
||||||
cable, // Câble
|
cable, // Câble
|
||||||
|
vehicle, // Véhicule
|
||||||
|
backline, // Régie / Backline
|
||||||
other // Autre
|
other // Autre
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ String equipmentCategoryToString(EquipmentCategory category) {
|
|||||||
return 'CONSUMABLE';
|
return 'CONSUMABLE';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'CABLE';
|
return 'CABLE';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'VEHICLE';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'BACKLINE';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'OTHER';
|
return 'OTHER';
|
||||||
case EquipmentCategory.effect:
|
case EquipmentCategory.effect:
|
||||||
@@ -93,6 +99,10 @@ EquipmentCategory equipmentCategoryFromString(String? category) {
|
|||||||
return EquipmentCategory.consumable;
|
return EquipmentCategory.consumable;
|
||||||
case 'CABLE':
|
case 'CABLE':
|
||||||
return EquipmentCategory.cable;
|
return EquipmentCategory.cable;
|
||||||
|
case 'VEHICLE':
|
||||||
|
return EquipmentCategory.vehicle;
|
||||||
|
case 'BACKLINE':
|
||||||
|
return EquipmentCategory.backline;
|
||||||
case 'EFFECT':
|
case 'EFFECT':
|
||||||
return EquipmentCategory.effect;
|
return EquipmentCategory.effect;
|
||||||
case 'OTHER':
|
case 'OTHER':
|
||||||
@@ -120,6 +130,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -142,6 +156,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Icons.inventory_2;
|
return Icons.inventory_2;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.more_horiz;
|
return Icons.more_horiz;
|
||||||
}
|
}
|
||||||
@@ -164,6 +182,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Colors.teal;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Colors.indigo;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Colors.blueGrey;
|
return Colors.blueGrey;
|
||||||
}
|
}
|
||||||
@@ -176,7 +198,14 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'assets/icons/truss.svg';
|
return 'assets/icons/truss.svg';
|
||||||
case EquipmentCategory.consumable:
|
case EquipmentCategory.consumable:
|
||||||
return 'assets/icons/tape.svg';
|
return 'assets/icons/tape.svg';
|
||||||
default:
|
case EquipmentCategory.lighting:
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
case EquipmentCategory.other:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +324,7 @@ class EquipmentModel {
|
|||||||
final String? brand; // Marque (indexé)
|
final String? brand; // Marque (indexé)
|
||||||
final String? model; // Modèle (indexé)
|
final String? model; // Modèle (indexé)
|
||||||
final EquipmentCategory category; // Catégorie
|
final EquipmentCategory category; // Catégorie
|
||||||
|
final String? subCategory; // Sous-catégorie (indexé par catégorie)
|
||||||
final EquipmentStatus status; // Statut actuel
|
final EquipmentStatus status; // Statut actuel
|
||||||
|
|
||||||
// Prix (visible uniquement avec manage_equipment)
|
// Prix (visible uniquement avec manage_equipment)
|
||||||
@@ -306,8 +336,6 @@ class EquipmentModel {
|
|||||||
final int? availableQuantity; // Quantité disponible
|
final int? availableQuantity; // Quantité disponible
|
||||||
final int? criticalThreshold; // Seuil critique pour alerte
|
final int? criticalThreshold; // Seuil critique pour alerte
|
||||||
|
|
||||||
// Boîtes parentes (plusieurs possibles)
|
|
||||||
final List<String> parentBoxIds; // IDs des boîtes contenant cet équipement
|
|
||||||
|
|
||||||
// Caractéristiques physiques
|
// Caractéristiques physiques
|
||||||
final double? weight; // Poids (kg)
|
final double? weight; // Poids (kg)
|
||||||
@@ -337,13 +365,13 @@ class EquipmentModel {
|
|||||||
this.brand,
|
this.brand,
|
||||||
this.model,
|
this.model,
|
||||||
required this.category,
|
required this.category,
|
||||||
|
this.subCategory,
|
||||||
this.status = EquipmentStatus.available,
|
this.status = EquipmentStatus.available,
|
||||||
this.purchasePrice,
|
this.purchasePrice,
|
||||||
this.rentalPrice,
|
this.rentalPrice,
|
||||||
this.totalQuantity,
|
this.totalQuantity,
|
||||||
this.availableQuantity,
|
this.availableQuantity,
|
||||||
this.criticalThreshold,
|
this.criticalThreshold,
|
||||||
this.parentBoxIds = const [],
|
|
||||||
this.weight,
|
this.weight,
|
||||||
this.length,
|
this.length,
|
||||||
this.width,
|
this.width,
|
||||||
@@ -359,10 +387,15 @@ class EquipmentModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Gestion des listes
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
final List<dynamic> parentBoxIdsRaw = map['parentBoxIds'] ?? [];
|
DateTime? _parseDate(dynamic value) {
|
||||||
final List<String> parentBoxIds = parentBoxIdsRaw.map((e) => e.toString()).toList();
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des listes
|
||||||
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||||
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
@@ -372,24 +405,24 @@ class EquipmentModel {
|
|||||||
brand: map['brand'],
|
brand: map['brand'],
|
||||||
model: map['model'],
|
model: map['model'],
|
||||||
category: equipmentCategoryFromString(map['category']),
|
category: equipmentCategoryFromString(map['category']),
|
||||||
|
subCategory: map['subCategory'],
|
||||||
status: equipmentStatusFromString(map['status']),
|
status: equipmentStatusFromString(map['status']),
|
||||||
purchasePrice: map['purchasePrice']?.toDouble(),
|
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||||
rentalPrice: map['rentalPrice']?.toDouble(),
|
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||||
totalQuantity: map['totalQuantity']?.toInt(),
|
totalQuantity: map['totalQuantity']?.toInt(),
|
||||||
availableQuantity: map['availableQuantity']?.toInt(),
|
availableQuantity: map['availableQuantity']?.toInt(),
|
||||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||||
parentBoxIds: parentBoxIds,
|
|
||||||
weight: map['weight']?.toDouble(),
|
weight: map['weight']?.toDouble(),
|
||||||
length: map['length']?.toDouble(),
|
length: map['length']?.toDouble(),
|
||||||
width: map['width']?.toDouble(),
|
width: map['width']?.toDouble(),
|
||||||
height: map['height']?.toDouble(),
|
height: map['height']?.toDouble(),
|
||||||
purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(),
|
purchaseDate: _parseDate(map['purchaseDate']),
|
||||||
nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(),
|
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
||||||
maintenanceIds: maintenanceIds,
|
maintenanceIds: maintenanceIds,
|
||||||
imageUrl: map['imageUrl'],
|
imageUrl: map['imageUrl'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,13 +432,13 @@ class EquipmentModel {
|
|||||||
'brand': brand,
|
'brand': brand,
|
||||||
'model': model,
|
'model': model,
|
||||||
'category': equipmentCategoryToString(category),
|
'category': equipmentCategoryToString(category),
|
||||||
|
'subCategory': subCategory,
|
||||||
'status': equipmentStatusToString(status),
|
'status': equipmentStatusToString(status),
|
||||||
'purchasePrice': purchasePrice,
|
'purchasePrice': purchasePrice,
|
||||||
'rentalPrice': rentalPrice,
|
'rentalPrice': rentalPrice,
|
||||||
'totalQuantity': totalQuantity,
|
'totalQuantity': totalQuantity,
|
||||||
'availableQuantity': availableQuantity,
|
'availableQuantity': availableQuantity,
|
||||||
'criticalThreshold': criticalThreshold,
|
'criticalThreshold': criticalThreshold,
|
||||||
'parentBoxIds': parentBoxIds,
|
|
||||||
'weight': weight,
|
'weight': weight,
|
||||||
'length': length,
|
'length': length,
|
||||||
'width': width,
|
'width': width,
|
||||||
@@ -427,13 +460,13 @@ class EquipmentModel {
|
|||||||
String? name,
|
String? name,
|
||||||
String? model,
|
String? model,
|
||||||
EquipmentCategory? category,
|
EquipmentCategory? category,
|
||||||
|
String? subCategory,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
double? purchasePrice,
|
double? purchasePrice,
|
||||||
double? rentalPrice,
|
double? rentalPrice,
|
||||||
int? totalQuantity,
|
int? totalQuantity,
|
||||||
int? availableQuantity,
|
int? availableQuantity,
|
||||||
int? criticalThreshold,
|
int? criticalThreshold,
|
||||||
List<String>? parentBoxIds,
|
|
||||||
double? weight,
|
double? weight,
|
||||||
double? length,
|
double? length,
|
||||||
double? width,
|
double? width,
|
||||||
@@ -453,13 +486,13 @@ class EquipmentModel {
|
|||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
model: model ?? this.model,
|
model: model ?? this.model,
|
||||||
category: category ?? this.category,
|
category: category ?? this.category,
|
||||||
|
subCategory: subCategory ?? this.subCategory,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
purchasePrice: purchasePrice ?? this.purchasePrice,
|
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||||
rentalPrice: rentalPrice ?? this.rentalPrice,
|
rentalPrice: rentalPrice ?? this.rentalPrice,
|
||||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||||
availableQuantity: availableQuantity ?? this.availableQuantity,
|
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||||
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||||
parentBoxIds: parentBoxIds ?? this.parentBoxIds,
|
|
||||||
weight: weight ?? this.weight,
|
weight: weight ?? this.weight,
|
||||||
length: length ?? this.length,
|
length: length ?? this.length,
|
||||||
width: width ?? this.width,
|
width: width ?? this.width,
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ String eventStatusToString(EventStatus status) {
|
|||||||
case EventStatus.canceled:
|
case EventStatus.canceled:
|
||||||
return 'CANCELED';
|
return 'CANCELED';
|
||||||
case EventStatus.waitingForApproval:
|
case EventStatus.waitingForApproval:
|
||||||
default:
|
return 'WAITING_FOR_APPROVAL';
|
||||||
return 'WAITING_FOR_APPROVAL';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +64,78 @@ PreparationStatus preparationStatusFromString(String? status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Statut de chargement (loading)
|
||||||
|
enum LoadingStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String loadingStatusToString(LoadingStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case LoadingStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case LoadingStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case LoadingStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case LoadingStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingStatus loadingStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return LoadingStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return LoadingStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return LoadingStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return LoadingStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return LoadingStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut de déchargement (unloading)
|
||||||
|
enum UnloadingStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String unloadingStatusToString(UnloadingStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case UnloadingStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case UnloadingStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case UnloadingStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case UnloadingStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnloadingStatus unloadingStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return UnloadingStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return UnloadingStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return UnloadingStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return UnloadingStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return UnloadingStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum ReturnStatus {
|
enum ReturnStatus {
|
||||||
notStarted,
|
notStarted,
|
||||||
inProgress,
|
inProgress,
|
||||||
@@ -102,17 +173,39 @@ ReturnStatus returnStatusFromString(String? status) {
|
|||||||
|
|
||||||
class EventEquipment {
|
class EventEquipment {
|
||||||
final String equipmentId; // ID de l'équipement
|
final String equipmentId; // ID de l'équipement
|
||||||
final int quantity; // Quantité (pour consommables)
|
final int quantity; // Quantité initiale assignée
|
||||||
final bool isPrepared; // Validé en préparation
|
final bool isPrepared; // Validé en préparation
|
||||||
|
final bool isLoaded; // Validé au chargement
|
||||||
|
final bool isUnloaded; // Validé au déchargement
|
||||||
final bool isReturned; // Validé au retour
|
final bool isReturned; // Validé au retour
|
||||||
final int? returnedQuantity; // Quantité retournée (pour consommables)
|
|
||||||
|
// Tracking des manquants à chaque étape
|
||||||
|
final bool isMissingAtPreparation; // Manquant à la préparation
|
||||||
|
final bool isMissingAtLoading; // Manquant au chargement
|
||||||
|
final bool isMissingAtUnloading; // Manquant au déchargement
|
||||||
|
final bool isMissingAtReturn; // Manquant au retour
|
||||||
|
|
||||||
|
// Quantités réelles à chaque étape (pour les quantifiables)
|
||||||
|
final int? quantityAtPreparation; // Quantité comptée en préparation
|
||||||
|
final int? quantityAtLoading; // Quantité comptée au chargement
|
||||||
|
final int? quantityAtUnloading; // Quantité comptée au déchargement
|
||||||
|
final int? quantityAtReturn; // Quantité retournée
|
||||||
|
|
||||||
EventEquipment({
|
EventEquipment({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
this.quantity = 1,
|
this.quantity = 1,
|
||||||
this.isPrepared = false,
|
this.isPrepared = false,
|
||||||
|
this.isLoaded = false,
|
||||||
|
this.isUnloaded = false,
|
||||||
this.isReturned = false,
|
this.isReturned = false,
|
||||||
this.returnedQuantity,
|
this.isMissingAtPreparation = false,
|
||||||
|
this.isMissingAtLoading = false,
|
||||||
|
this.isMissingAtUnloading = false,
|
||||||
|
this.isMissingAtReturn = false,
|
||||||
|
this.quantityAtPreparation,
|
||||||
|
this.quantityAtLoading,
|
||||||
|
this.quantityAtUnloading,
|
||||||
|
this.quantityAtReturn,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
||||||
@@ -120,8 +213,17 @@ class EventEquipment {
|
|||||||
equipmentId: map['equipmentId'] ?? '',
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
quantity: map['quantity'] ?? 1,
|
quantity: map['quantity'] ?? 1,
|
||||||
isPrepared: map['isPrepared'] ?? false,
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
isReturned: map['isReturned'] ?? false,
|
isReturned: map['isReturned'] ?? false,
|
||||||
returnedQuantity: map['returnedQuantity'],
|
isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
|
||||||
|
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
|
||||||
|
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
|
||||||
|
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
|
||||||
|
quantityAtPreparation: map['quantityAtPreparation'],
|
||||||
|
quantityAtLoading: map['quantityAtLoading'],
|
||||||
|
quantityAtUnloading: map['quantityAtUnloading'],
|
||||||
|
quantityAtReturn: map['quantityAtReturn'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +232,17 @@ class EventEquipment {
|
|||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'isPrepared': isPrepared,
|
'isPrepared': isPrepared,
|
||||||
|
'isLoaded': isLoaded,
|
||||||
|
'isUnloaded': isUnloaded,
|
||||||
'isReturned': isReturned,
|
'isReturned': isReturned,
|
||||||
'returnedQuantity': returnedQuantity,
|
'isMissingAtPreparation': isMissingAtPreparation,
|
||||||
|
'isMissingAtLoading': isMissingAtLoading,
|
||||||
|
'isMissingAtUnloading': isMissingAtUnloading,
|
||||||
|
'isMissingAtReturn': isMissingAtReturn,
|
||||||
|
'quantityAtPreparation': quantityAtPreparation,
|
||||||
|
'quantityAtLoading': quantityAtLoading,
|
||||||
|
'quantityAtUnloading': quantityAtUnloading,
|
||||||
|
'quantityAtReturn': quantityAtReturn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,15 +250,33 @@ class EventEquipment {
|
|||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
bool? isPrepared,
|
bool? isPrepared,
|
||||||
|
bool? isLoaded,
|
||||||
|
bool? isUnloaded,
|
||||||
bool? isReturned,
|
bool? isReturned,
|
||||||
int? returnedQuantity,
|
bool? isMissingAtPreparation,
|
||||||
|
bool? isMissingAtLoading,
|
||||||
|
bool? isMissingAtUnloading,
|
||||||
|
bool? isMissingAtReturn,
|
||||||
|
int? quantityAtPreparation,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
int? quantityAtUnloading,
|
||||||
|
int? quantityAtReturn,
|
||||||
}) {
|
}) {
|
||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
isPrepared: isPrepared ?? this.isPrepared,
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
isReturned: isReturned ?? this.isReturned,
|
isReturned: isReturned ?? this.isReturned,
|
||||||
returnedQuantity: returnedQuantity ?? this.returnedQuantity,
|
isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
|
||||||
|
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
|
||||||
|
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
|
||||||
|
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
|
||||||
|
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
|
||||||
|
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
|
||||||
|
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
|
||||||
|
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +296,7 @@ class EventModel {
|
|||||||
final String address;
|
final String address;
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
final List<DocumentReference> workforce;
|
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
|
||||||
final List<Map<String, String>> documents;
|
final List<Map<String, String>> documents;
|
||||||
final List<Map<String, dynamic>> options;
|
final List<Map<String, dynamic>> options;
|
||||||
final EventStatus status;
|
final EventStatus status;
|
||||||
@@ -181,6 +310,8 @@ class EventModel {
|
|||||||
final List<EventEquipment> assignedEquipment;
|
final List<EventEquipment> assignedEquipment;
|
||||||
final List<String> assignedContainers; // IDs des conteneurs assignés
|
final List<String> assignedContainers; // IDs des conteneurs assignés
|
||||||
final PreparationStatus? preparationStatus;
|
final PreparationStatus? preparationStatus;
|
||||||
|
final LoadingStatus? loadingStatus;
|
||||||
|
final UnloadingStatus? unloadingStatus;
|
||||||
final ReturnStatus? returnStatus;
|
final ReturnStatus? returnStatus;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
@@ -208,30 +339,39 @@ class EventModel {
|
|||||||
this.assignedEquipment = const [],
|
this.assignedEquipment = const [],
|
||||||
this.assignedContainers = const [],
|
this.assignedContainers = const [],
|
||||||
this.preparationStatus,
|
this.preparationStatus,
|
||||||
|
this.loadingStatus,
|
||||||
|
this.unloadingStatus,
|
||||||
this.returnStatus,
|
this.returnStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
try {
|
try {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des références workforce
|
// Gestion sécurisée des références workforce
|
||||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||||
final List<DocumentReference> safeWorkforce = [];
|
final List<dynamic> safeWorkforce = [];
|
||||||
|
|
||||||
for (var ref in workforceRefs) {
|
for (var ref in workforceRefs) {
|
||||||
if (ref is DocumentReference) {
|
if (ref is DocumentReference) {
|
||||||
safeWorkforce.add(ref);
|
safeWorkforce.add(ref);
|
||||||
|
} else if (ref is String) {
|
||||||
|
// Accepter directement les UIDs (envoyés par le backend)
|
||||||
|
safeWorkforce.add(ref);
|
||||||
} else {
|
} else {
|
||||||
print('Warning: Invalid workforce reference in event $id: $ref');
|
print('Warning: Invalid workforce reference in event $id: $ref');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des timestamps
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
|
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
||||||
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
|
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
final DateTime startDate = startTimestamp?.toDate() ?? DateTime.now();
|
|
||||||
final DateTime endDate = endTimestamp?.toDate() ??
|
|
||||||
startDate.add(const Duration(hours: 1));
|
|
||||||
|
|
||||||
// Gestion sécurisée des documents
|
// Gestion sécurisée des documents
|
||||||
final docsRaw = map['documents'] ?? [];
|
final docsRaw = map['documents'] ?? [];
|
||||||
@@ -278,7 +418,13 @@ class EventModel {
|
|||||||
eventTypeRef = map['EventType'] as DocumentReference;
|
eventTypeRef = map['EventType'] as DocumentReference;
|
||||||
eventTypeId = eventTypeRef.id;
|
eventTypeId = eventTypeRef.id;
|
||||||
} else if (map['EventType'] is String) {
|
} else if (map['EventType'] is String) {
|
||||||
eventTypeId = map['EventType'] as String;
|
final eventTypeString = map['EventType'] as String;
|
||||||
|
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
|
||||||
|
if (eventTypeString.contains('/')) {
|
||||||
|
eventTypeId = eventTypeString.split('/').last;
|
||||||
|
} else {
|
||||||
|
eventTypeId = eventTypeString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée du customer
|
// Gestion sécurisée du customer
|
||||||
@@ -286,7 +432,13 @@ class EventModel {
|
|||||||
if (map['customer'] is DocumentReference) {
|
if (map['customer'] is DocumentReference) {
|
||||||
customerId = (map['customer'] as DocumentReference).id;
|
customerId = (map['customer'] as DocumentReference).id;
|
||||||
} else if (map['customer'] is String) {
|
} else if (map['customer'] is String) {
|
||||||
customerId = map['customer'] as String;
|
final customerString = map['customer'] as String;
|
||||||
|
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
|
||||||
|
if (customerString.contains('/')) {
|
||||||
|
customerId = customerString.split('/').last;
|
||||||
|
} else {
|
||||||
|
customerId = customerString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion des équipements assignés
|
// Gestion des équipements assignés
|
||||||
@@ -342,6 +494,8 @@ class EventModel {
|
|||||||
contactPhone: map['contactPhone']?.toString(),
|
contactPhone: map['contactPhone']?.toString(),
|
||||||
assignedEquipment: assignedEquipment,
|
assignedEquipment: assignedEquipment,
|
||||||
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
|
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
|
||||||
|
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
|
||||||
|
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
|
||||||
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
|
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -381,12 +535,10 @@ class EventModel {
|
|||||||
'BasePrice': basePrice,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId.isNotEmpty
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId)
|
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||||
: null,
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
'customer': customerId.isNotEmpty
|
'customer': customerId.isNotEmpty ? customerId : null,
|
||||||
? FirebaseFirestore.instance.collection('customers').doc(customerId)
|
|
||||||
: null,
|
|
||||||
'Address': address,
|
'Address': address,
|
||||||
'Position': GeoPoint(latitude, longitude),
|
'Position': GeoPoint(latitude, longitude),
|
||||||
'Latitude': latitude,
|
'Latitude': latitude,
|
||||||
@@ -401,7 +553,69 @@ class EventModel {
|
|||||||
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
||||||
'assignedContainers': assignedContainers,
|
'assignedContainers': assignedContainers,
|
||||||
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
||||||
|
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
|
||||||
|
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null,
|
||||||
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EventModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
DateTime? startDateTime,
|
||||||
|
DateTime? endDateTime,
|
||||||
|
double? basePrice,
|
||||||
|
int? installationTime,
|
||||||
|
int? disassemblyTime,
|
||||||
|
String? eventTypeId,
|
||||||
|
DocumentReference? eventTypeRef,
|
||||||
|
String? customerId,
|
||||||
|
String? address,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
List<dynamic>? workforce,
|
||||||
|
List<Map<String, String>>? documents,
|
||||||
|
List<Map<String, dynamic>>? options,
|
||||||
|
EventStatus? status,
|
||||||
|
int? jauge,
|
||||||
|
String? contactEmail,
|
||||||
|
String? contactPhone,
|
||||||
|
List<EventEquipment>? assignedEquipment,
|
||||||
|
List<String>? assignedContainers,
|
||||||
|
PreparationStatus? preparationStatus,
|
||||||
|
LoadingStatus? loadingStatus,
|
||||||
|
UnloadingStatus? unloadingStatus,
|
||||||
|
ReturnStatus? returnStatus,
|
||||||
|
}) {
|
||||||
|
return EventModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
startDateTime: startDateTime ?? this.startDateTime,
|
||||||
|
endDateTime: endDateTime ?? this.endDateTime,
|
||||||
|
basePrice: basePrice ?? this.basePrice,
|
||||||
|
installationTime: installationTime ?? this.installationTime,
|
||||||
|
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
|
||||||
|
eventTypeId: eventTypeId ?? this.eventTypeId,
|
||||||
|
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
|
||||||
|
customerId: customerId ?? this.customerId,
|
||||||
|
address: address ?? this.address,
|
||||||
|
latitude: latitude ?? this.latitude,
|
||||||
|
longitude: longitude ?? this.longitude,
|
||||||
|
workforce: workforce ?? this.workforce,
|
||||||
|
documents: documents ?? this.documents,
|
||||||
|
options: options ?? this.options,
|
||||||
|
status: status ?? this.status,
|
||||||
|
jauge: jauge ?? this.jauge,
|
||||||
|
contactEmail: contactEmail ?? this.contactEmail,
|
||||||
|
contactPhone: contactPhone ?? this.contactPhone,
|
||||||
|
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
|
||||||
|
assignedContainers: assignedContainers ?? this.assignedContainers,
|
||||||
|
preparationStatus: preparationStatus ?? this.preparationStatus,
|
||||||
|
loadingStatus: loadingStatus ?? this.loadingStatus,
|
||||||
|
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
|
||||||
|
returnStatus: returnStatus ?? this.returnStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class EventTypeModel {
|
class EventTypeModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -12,11 +14,19 @@ class EventTypeModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
|
||||||
|
DateTime parseCreatedAt(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
return EventTypeModel(
|
return EventTypeModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
||||||
createdAt: map['createdAt']?.toDate() ?? DateTime.now(),
|
createdAt: parseCreatedAt(map['createdAt']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ class MaintenanceModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Gestion de la liste des équipements
|
// Gestion de la liste des équipements
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
@@ -68,15 +76,15 @@ class MaintenanceModel {
|
|||||||
id: id,
|
id: id,
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
type: maintenanceTypeFromString(map['type']),
|
type: maintenanceTypeFromString(map['type']),
|
||||||
scheduledDate: (map['scheduledDate'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
completedDate: (map['completedDate'] as Timestamp?)?.toDate(),
|
completedDate: _parseDate(map['completedDate']),
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
description: map['description'] ?? '',
|
description: map['description'] ?? '',
|
||||||
performedBy: map['performedBy'],
|
performedBy: map['performedBy'],
|
||||||
cost: map['cost']?.toDouble(),
|
cost: map['cost']?.toDouble(),
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/// Préférences de notifications pour un utilisateur
|
||||||
|
class NotificationPreferences {
|
||||||
|
final bool emailEnabled; // Recevoir emails
|
||||||
|
final bool pushEnabled; // Recevoir notifications push
|
||||||
|
final bool inAppEnabled; // Recevoir alertes in-app
|
||||||
|
|
||||||
|
// Préférences par type d'alerte
|
||||||
|
final bool eventsNotifications; // Alertes événements
|
||||||
|
final bool maintenanceNotifications; // Alertes maintenance
|
||||||
|
final bool stockNotifications; // Alertes stock
|
||||||
|
final bool equipmentNotifications; // Alertes équipement
|
||||||
|
|
||||||
|
// Token FCM (pour push)
|
||||||
|
final String? fcmToken;
|
||||||
|
|
||||||
|
const NotificationPreferences({
|
||||||
|
this.emailEnabled = true, // ✓ Activé par défaut
|
||||||
|
this.pushEnabled = false,
|
||||||
|
this.inAppEnabled = true,
|
||||||
|
this.eventsNotifications = true,
|
||||||
|
this.maintenanceNotifications = true,
|
||||||
|
this.stockNotifications = true,
|
||||||
|
this.equipmentNotifications = true,
|
||||||
|
this.fcmToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Valeurs par défaut pour un nouvel utilisateur
|
||||||
|
factory NotificationPreferences.defaults() {
|
||||||
|
return const NotificationPreferences(
|
||||||
|
emailEnabled: true, // ✓ Activé par défaut
|
||||||
|
pushEnabled: false,
|
||||||
|
inAppEnabled: true,
|
||||||
|
eventsNotifications: true,
|
||||||
|
maintenanceNotifications: true,
|
||||||
|
stockNotifications: true,
|
||||||
|
equipmentNotifications: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
|
||||||
|
return NotificationPreferences(
|
||||||
|
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
|
||||||
|
pushEnabled: map['pushEnabled'] ?? false,
|
||||||
|
inAppEnabled: map['inAppEnabled'] ?? true,
|
||||||
|
eventsNotifications: map['eventsNotifications'] ?? true,
|
||||||
|
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
|
||||||
|
stockNotifications: map['stockNotifications'] ?? true,
|
||||||
|
equipmentNotifications: map['equipmentNotifications'] ?? true,
|
||||||
|
fcmToken: map['fcmToken'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'emailEnabled': emailEnabled,
|
||||||
|
'pushEnabled': pushEnabled,
|
||||||
|
'inAppEnabled': inAppEnabled,
|
||||||
|
'eventsNotifications': eventsNotifications,
|
||||||
|
'maintenanceNotifications': maintenanceNotifications,
|
||||||
|
'stockNotifications': stockNotifications,
|
||||||
|
'equipmentNotifications': equipmentNotifications,
|
||||||
|
if (fcmToken != null) 'fcmToken': fcmToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationPreferences copyWith({
|
||||||
|
bool? emailEnabled,
|
||||||
|
bool? pushEnabled,
|
||||||
|
bool? inAppEnabled,
|
||||||
|
bool? eventsNotifications,
|
||||||
|
bool? maintenanceNotifications,
|
||||||
|
bool? stockNotifications,
|
||||||
|
bool? equipmentNotifications,
|
||||||
|
String? fcmToken,
|
||||||
|
}) {
|
||||||
|
return NotificationPreferences(
|
||||||
|
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||||
|
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||||
|
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
|
||||||
|
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
|
||||||
|
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
|
||||||
|
stockNotifications: stockNotifications ?? this.stockNotifications,
|
||||||
|
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
|
||||||
|
fcmToken: fcmToken ?? this.fcmToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
em2rp/lib/models/qr_code_process_result.dart
Normal file
63
em2rp/lib/models/qr_code_process_result.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/// Résultat du traitement d'un code QR ou saisi manuellement
|
||||||
|
class QRCodeProcessResult {
|
||||||
|
/// Indique si le traitement a réussi
|
||||||
|
final bool success;
|
||||||
|
|
||||||
|
/// Message descriptif du résultat
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
/// Liste des IDs d'équipements affectés par le traitement
|
||||||
|
final List<String> affectedEquipmentIds;
|
||||||
|
|
||||||
|
/// Mises à jour des états de validation (équipements cochés)
|
||||||
|
final Map<String, bool>? updatedValidationState;
|
||||||
|
|
||||||
|
/// Mises à jour des quantités actuelles
|
||||||
|
final Map<String, int>? updatedQuantities;
|
||||||
|
|
||||||
|
/// Indique si le code n'a pas été trouvé dans l'événement actuel
|
||||||
|
/// (utilisé pour proposer de l'ajouter depuis la BDD)
|
||||||
|
final bool codeNotFoundInEvent;
|
||||||
|
|
||||||
|
const QRCodeProcessResult({
|
||||||
|
required this.success,
|
||||||
|
this.message,
|
||||||
|
this.affectedEquipmentIds = const [],
|
||||||
|
this.updatedValidationState,
|
||||||
|
this.updatedQuantities,
|
||||||
|
this.codeNotFoundInEvent = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Crée un résultat de succès
|
||||||
|
factory QRCodeProcessResult.success({
|
||||||
|
required String message,
|
||||||
|
required List<String> affectedEquipmentIds,
|
||||||
|
Map<String, bool>? updatedValidationState,
|
||||||
|
Map<String, int>? updatedQuantities,
|
||||||
|
}) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: true,
|
||||||
|
message: message,
|
||||||
|
affectedEquipmentIds: affectedEquipmentIds,
|
||||||
|
updatedValidationState: updatedValidationState,
|
||||||
|
updatedQuantities: updatedQuantities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un résultat d'erreur
|
||||||
|
factory QRCodeProcessResult.error(String message) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: false,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un résultat indiquant que le code n'est pas dans l'événement
|
||||||
|
factory QRCodeProcessResult.notFoundInEvent(String code) {
|
||||||
|
return QRCodeProcessResult(
|
||||||
|
success: false,
|
||||||
|
message: 'Code $code non trouvé dans cet événement',
|
||||||
|
codeNotFoundInEvent: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||||
|
|
||||||
class UserModel {
|
class UserModel {
|
||||||
final String uid;
|
final String uid;
|
||||||
@@ -8,6 +9,7 @@ class UserModel {
|
|||||||
final String profilePhotoUrl;
|
final String profilePhotoUrl;
|
||||||
final String email;
|
final String email;
|
||||||
final String phoneNumber;
|
final String phoneNumber;
|
||||||
|
final NotificationPreferences? notificationPreferences;
|
||||||
|
|
||||||
UserModel({
|
UserModel({
|
||||||
required this.uid,
|
required this.uid,
|
||||||
@@ -17,19 +19,39 @@ class UserModel {
|
|||||||
required this.profilePhotoUrl,
|
required this.profilePhotoUrl,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.phoneNumber,
|
required this.phoneNumber,
|
||||||
|
this.notificationPreferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convertit une Map (Firestore) en UserModel
|
// Convertit une Map (Firestore) en UserModel
|
||||||
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
|
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
|
||||||
String roleString;
|
String roleString;
|
||||||
final roleField = data['role'];
|
final roleField = data['role'];
|
||||||
|
|
||||||
if (roleField is String) {
|
if (roleField is String) {
|
||||||
|
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
|
||||||
roleString = roleField;
|
roleString = roleField;
|
||||||
} else if (roleField is DocumentReference) {
|
} else if (roleField is DocumentReference) {
|
||||||
|
// Cas 2 : role est une DocumentReference
|
||||||
roleString = roleField.id;
|
roleString = roleField.id;
|
||||||
|
} else if (roleField is Map) {
|
||||||
|
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
|
||||||
|
// On extrait le path
|
||||||
|
final pathData = roleField['_path'];
|
||||||
|
if (pathData is Map && pathData['segments'] is List) {
|
||||||
|
final segments = pathData['segments'] as List;
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
roleString = segments[1].toString(); // Ex: "ADMIN"
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Cas par défaut
|
||||||
roleString = 'USER';
|
roleString = 'USER';
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
firstName: data['firstName'] ?? '',
|
firstName: data['firstName'] ?? '',
|
||||||
@@ -38,6 +60,9 @@ class UserModel {
|
|||||||
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
||||||
email: data['email'] ?? '',
|
email: data['email'] ?? '',
|
||||||
phoneNumber: data['phoneNumber'] ?? '',
|
phoneNumber: data['phoneNumber'] ?? '',
|
||||||
|
notificationPreferences: data['notificationPreferences'] != null
|
||||||
|
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
|
||||||
|
: NotificationPreferences.defaults(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +71,12 @@ class UserModel {
|
|||||||
return {
|
return {
|
||||||
'firstName': firstName,
|
'firstName': firstName,
|
||||||
'lastName': lastName,
|
'lastName': lastName,
|
||||||
'role': FirebaseFirestore.instance.collection('roles').doc(role),
|
'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference
|
||||||
'profilePhotoUrl': profilePhotoUrl,
|
'profilePhotoUrl': profilePhotoUrl,
|
||||||
'email': email,
|
'email': email,
|
||||||
'phoneNumber': phoneNumber,
|
'phoneNumber': phoneNumber,
|
||||||
|
if (notificationPreferences != null)
|
||||||
|
'notificationPreferences': notificationPreferences!.toMap(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +87,7 @@ class UserModel {
|
|||||||
String? profilePhotoUrl,
|
String? profilePhotoUrl,
|
||||||
String? email,
|
String? email,
|
||||||
String? phoneNumber,
|
String? phoneNumber,
|
||||||
|
NotificationPreferences? notificationPreferences,
|
||||||
}) {
|
}) {
|
||||||
return UserModel(
|
return UserModel(
|
||||||
uid: uid, // L'UID ne change pas
|
uid: uid, // L'UID ne change pas
|
||||||
@@ -69,6 +97,7 @@ class UserModel {
|
|||||||
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||||
|
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class AlertProvider extends ChangeNotifier {
|
class AlertProvider extends ChangeNotifier {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
List<AlertModel> _alerts = [];
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<AlertModel> get alerts => _alerts;
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
/// Nombre d'alertes non lues
|
/// Nombre d'alertes non lues
|
||||||
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
||||||
@@ -25,57 +27,58 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
/// Alertes de conflit
|
/// Alertes de conflit
|
||||||
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
||||||
|
|
||||||
/// Stream des alertes
|
/// Charger toutes les alertes via Cloud Function
|
||||||
Stream<List<AlertModel>> get alertsStream {
|
Future<void> loadAlerts() async {
|
||||||
return _firestore
|
_isLoading = true;
|
||||||
.collection('alerts')
|
notifyListeners();
|
||||||
.orderBy('createdAt', descending: true)
|
|
||||||
.snapshots()
|
try {
|
||||||
.map((snapshot) {
|
final result = await _apiService.call('getAlerts', {});
|
||||||
_alerts = snapshot.docs
|
final alertsData = result['alerts'] as List<dynamic>;
|
||||||
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
_alerts = alertsData.map((data) {
|
||||||
return _alerts;
|
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||||
});
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading alerts: $e');
|
||||||
|
_alerts = [];
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marquer une alerte comme lue
|
/// Marquer une alerte comme lue via Cloud Function
|
||||||
Future<void> markAsRead(String alertId) async {
|
Future<void> markAsRead(String alertId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('alerts').doc(alertId).update({
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
'isRead': true,
|
|
||||||
});
|
// Mettre à jour localement
|
||||||
notifyListeners();
|
final index = _alerts.indexWhere((a) => a.id == alertId);
|
||||||
|
if (index != -1) {
|
||||||
|
_alerts[index] = AlertModel(
|
||||||
|
id: _alerts[index].id,
|
||||||
|
type: _alerts[index].type,
|
||||||
|
message: _alerts[index].message,
|
||||||
|
equipmentId: _alerts[index].equipmentId,
|
||||||
|
isRead: true,
|
||||||
|
createdAt: _alerts[index].createdAt,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error marking alert as read: $e');
|
print('Error marking alert as read: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marquer toutes les alertes comme lues
|
/// Supprimer une alerte via Cloud Function
|
||||||
Future<void> markAllAsRead() async {
|
|
||||||
try {
|
|
||||||
final batch = _firestore.batch();
|
|
||||||
|
|
||||||
for (var alert in _alerts.where((a) => !a.isRead)) {
|
|
||||||
batch.update(
|
|
||||||
_firestore.collection('alerts').doc(alert.id),
|
|
||||||
{'isRead': true},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await batch.commit();
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error marking all alerts as read: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Supprimer une alerte
|
|
||||||
Future<void> deleteAlert(String alertId) async {
|
Future<void> deleteAlert(String alertId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('alerts').doc(alertId).delete();
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Supprimer localement
|
||||||
|
_alerts.removeWhere((a) => a.id == alertId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting alert: $e');
|
print('Error deleting alert: $e');
|
||||||
@@ -83,46 +86,32 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer toutes les alertes lues
|
/// Marquer toutes les alertes comme lues
|
||||||
|
Future<void> markAllAsRead() async {
|
||||||
|
try {
|
||||||
|
final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
|
for (final alertId in unreadAlertIds) {
|
||||||
|
await markAsRead(alertId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error marking all alerts as read: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer toutes les alertes lues via Cloud Function
|
||||||
Future<void> deleteReadAlerts() async {
|
Future<void> deleteReadAlerts() async {
|
||||||
try {
|
try {
|
||||||
final batch = _firestore.batch();
|
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
for (var alert in _alerts.where((a) => a.isRead)) {
|
for (final alertId in readAlertIds) {
|
||||||
batch.delete(_firestore.collection('alerts').doc(alert.id));
|
await deleteAlert(alertId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await batch.commit();
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting read alerts: $e');
|
print('Error deleting read alerts: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une alerte (utilisé principalement par les services)
|
|
||||||
Future<void> createAlert(AlertModel alert) async {
|
|
||||||
try {
|
|
||||||
await _firestore.collection('alerts').doc(alert.id).set(alert.toMap());
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error creating alert: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer les alertes pour un équipement spécifique
|
|
||||||
Stream<List<AlertModel>> getAlertsForEquipment(String equipmentId) {
|
|
||||||
return _firestore
|
|
||||||
.collection('alerts')
|
|
||||||
.where('equipmentId', isEqualTo: equipmentId)
|
|
||||||
.orderBy('createdAt', descending: true)
|
|
||||||
.snapshots()
|
|
||||||
.map((snapshot) {
|
|
||||||
return snapshot.docs
|
|
||||||
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
em2rp/lib/providers/alert_provider_new.dart
Normal file
62
em2rp/lib/providers/alert_provider_new.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
class AlertProvider extends ChangeNotifier {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Nombre d'alertes non lues
|
||||||
|
int get unreadCount => _alerts.where((a) => !a.isRead).length;
|
||||||
|
|
||||||
|
/// Charger toutes les alertes via l'API
|
||||||
|
Future<void> loadAlerts() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final alertsData = await _dataService.getAlerts();
|
||||||
|
|
||||||
|
_alerts = alertsData.map((data) {
|
||||||
|
return AlertModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading alerts: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharger les alertes
|
||||||
|
Future<void> refresh() async {
|
||||||
|
await loadAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes non lues
|
||||||
|
List<AlertModel> get unreadAlerts {
|
||||||
|
return _alerts.where((a) => !a.isRead).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes par type
|
||||||
|
List<AlertModel> getByType(AlertType type) {
|
||||||
|
return _alerts.where((a) => a.type == type).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes critiques (stock bas, équipement perdu)
|
||||||
|
List<AlertModel> get criticalAlerts {
|
||||||
|
return _alerts.where((a) =>
|
||||||
|
a.type == AlertType.lowStock || a.type == AlertType.lost
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,44 +1,268 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/services/container_service.dart';
|
import 'package:em2rp/services/container_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class ContainerProvider with ChangeNotifier {
|
class ContainerProvider with ChangeNotifier {
|
||||||
final ContainerService _containerService = ContainerService();
|
final ContainerService _containerService = ContainerService();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<ContainerModel> _paginatedContainers = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour compatibilité
|
||||||
|
List<ContainerModel> _containers = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
EquipmentStatus? _selectedStatus;
|
EquipmentStatus? _selectedStatus;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
|
||||||
ContainerType? get selectedType => _selectedType;
|
ContainerType? get selectedType => _selectedType;
|
||||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
String get searchQuery => _searchQuery;
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
/// Stream des containers avec filtres appliqués
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||||
Stream<List<ContainerModel>> get containersStream {
|
Future<void> ensureLoaded() async {
|
||||||
return _containerService.getContainers(
|
if (_isInitialized || _isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger tous les containers via l'API (avec pagination automatique)
|
||||||
|
Future<void> loadContainers() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_containers.clear();
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
int pageCount = 0;
|
||||||
|
|
||||||
|
// Charger toutes les pages en boucle
|
||||||
|
while (hasMore) {
|
||||||
|
pageCount++;
|
||||||
|
print('[ContainerProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
type: _selectedType?.name,
|
||||||
|
status: _selectedStatus?.name,
|
||||||
|
searchQuery: _searchQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
final containers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_containers.addAll(containers);
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
_isInitialized = true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading containers: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les containers avec filtres appliqués
|
||||||
|
Future<List<ContainerModel>> getContainers() async {
|
||||||
|
return await _containerService.getContainers(
|
||||||
type: _selectedType,
|
type: _selectedType,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
searchQuery: _searchQuery,
|
searchQuery: _searchQuery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream des containers - retourne un stream depuis les données en cache
|
||||||
|
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
|
||||||
|
Stream<List<ContainerModel>> get containersStream async* {
|
||||||
|
// Si les données ne sont pas chargées, charger d'abord
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Émettre les données actuelles
|
||||||
|
yield _containers;
|
||||||
|
|
||||||
|
// Continuer à émettre les mises à jour du cache
|
||||||
|
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
|
||||||
|
// et notifier quand les données changent
|
||||||
|
}
|
||||||
|
|
||||||
/// Définir le type sélectionné
|
/// Définir le type sélectionné
|
||||||
void setSelectedType(ContainerType? type) {
|
void setSelectedType(ContainerType? type) async {
|
||||||
|
if (_selectedType == type) return;
|
||||||
_selectedType = type;
|
_selectedType = type;
|
||||||
notifyListeners();
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Définir le statut sélectionné
|
/// Définir le statut sélectionné
|
||||||
void setSelectedStatus(EquipmentStatus? status) {
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
_selectedStatus = status;
|
_selectedStatus = status;
|
||||||
notifyListeners();
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Définir la requête de recherche
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
_searchQuery = query;
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION - Nouvelles méthodes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Active le mode pagination (pour la page de gestion)
|
||||||
|
void enablePagination() {
|
||||||
|
if (!_usePagination) {
|
||||||
|
_usePagination = true;
|
||||||
|
DebugLog.info('[ContainerProvider] Pagination mode enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode pagination (pour les autres pages)
|
||||||
|
void disablePagination() {
|
||||||
|
if (_usePagination) {
|
||||||
|
_usePagination = false;
|
||||||
|
DebugLog.info('[ContainerProvider] Pagination mode disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la première page (réinitialise tout)
|
||||||
|
Future<void> loadFirstPage() async {
|
||||||
|
DebugLog.info('[ContainerProvider] Loading first page...');
|
||||||
|
|
||||||
|
_paginatedContainers.clear();
|
||||||
|
_lastVisible = null;
|
||||||
|
_hasMore = true;
|
||||||
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadNextPage();
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading first page', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la page suivante (scroll infini)
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMore) {
|
||||||
|
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 20,
|
||||||
|
startAfter: _lastVisible,
|
||||||
|
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newContainers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) {
|
||||||
|
final map = data as Map<String, dynamic>;
|
||||||
|
return ContainerModel.fromMap(map, map['id'] as String);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_paginatedContainers.addAll(newContainers);
|
||||||
|
_hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
|
||||||
|
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading next page', e);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharge en changeant de filtre ou recherche
|
||||||
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[ContainerProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer un nouveau container
|
/// Créer un nouveau container
|
||||||
@@ -64,6 +288,69 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
return await _containerService.getContainerById(id);
|
return await _containerService.getContainerById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
|
||||||
|
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier d'abord le cache local
|
||||||
|
final cachedContainers = <ContainerModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in containerIds) {
|
||||||
|
final cached = _containers.firstWhere(
|
||||||
|
(c) => c.id == id,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached.id.isNotEmpty) {
|
||||||
|
cachedContainers.add(cached);
|
||||||
|
} else {
|
||||||
|
missingIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
|
// Si tous sont en cache, retourner directement
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return cachedContainers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les manquants depuis l'API
|
||||||
|
final containersData = await _dataService.getContainersByIds(missingIds);
|
||||||
|
|
||||||
|
final loadedContainers = containersData.map((data) {
|
||||||
|
return ContainerModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Ajouter au cache
|
||||||
|
for (final container in loadedContainers) {
|
||||||
|
if (!_containers.any((c) => c.id == container.id)) {
|
||||||
|
_containers.add(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||||
|
|
||||||
|
// Retourner tous les conteneurs (cache + chargés)
|
||||||
|
return [...cachedContainers, ...loadedContainers];
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerProvider] Error loading containers by IDs: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ajouter un équipement à un container
|
/// Ajouter un équipement à un container
|
||||||
Future<Map<String, dynamic>> addEquipmentToContainer({
|
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||||
required String containerId,
|
required String containerId,
|
||||||
|
|||||||
@@ -1,217 +1,533 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class EquipmentProvider extends ChangeNotifier {
|
class EquipmentProvider extends ChangeNotifier {
|
||||||
final EquipmentService _service = EquipmentService();
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<EquipmentModel> _paginatedEquipment = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||||
List<EquipmentModel> _equipment = [];
|
List<EquipmentModel> _equipment = [];
|
||||||
List<String> _models = [];
|
List<String> _models = [];
|
||||||
List<String> _brands = [];
|
List<String> _brands = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
EquipmentStatus? _selectedStatus;
|
EquipmentStatus? _selectedStatus;
|
||||||
String? _selectedModel;
|
String? _selectedModel;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
EquipmentProvider();
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<EquipmentModel> get equipment => _equipment;
|
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
||||||
|
List<EquipmentModel> get allEquipment => _equipment;
|
||||||
List<String> get models => _models;
|
List<String> get models => _models;
|
||||||
List<String> get brands => _brands;
|
List<String> get brands => _brands;
|
||||||
EquipmentCategory? get selectedCategory => _selectedCategory;
|
EquipmentCategory? get selectedCategory => _selectedCategory;
|
||||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
String? get selectedModel => _selectedModel;
|
String? get selectedModel => _selectedModel;
|
||||||
String get searchQuery => _searchQuery;
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
/// Stream des équipements avec filtres appliqués
|
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||||
Stream<List<EquipmentModel>> get equipmentStream {
|
Future<void> ensureLoaded() async {
|
||||||
return _service.getEquipment(
|
// Si déjà en train de charger, attendre
|
||||||
category: _selectedCategory,
|
if (_isLoading) {
|
||||||
status: _selectedStatus,
|
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
||||||
model: _selectedModel,
|
return;
|
||||||
searchQuery: _searchQuery,
|
}
|
||||||
);
|
|
||||||
|
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
||||||
|
if (_isInitialized && _equipment.isEmpty) {
|
||||||
|
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
||||||
|
_isInitialized = false; // Réinitialiser le flag
|
||||||
|
await loadEquipments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si déjà initialisé avec des données, ne rien faire
|
||||||
|
if (_isInitialized) {
|
||||||
|
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Equipment not loaded, loading now...');
|
||||||
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charger tous les modèles uniques
|
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
|
||||||
Future<void> loadModels() async {
|
Future<void> loadEquipments() async {
|
||||||
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_models = await _service.getAllModels();
|
_equipment.clear();
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
int pageCount = 0;
|
||||||
|
|
||||||
|
// Charger toutes les pages en boucle
|
||||||
|
while (hasMore) {
|
||||||
|
pageCount++;
|
||||||
|
print('[EquipmentProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentsData = result['equipments'] as List<dynamic>;
|
||||||
|
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
|
||||||
|
|
||||||
|
final pageEquipments = equipmentsData.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_equipment.addAll(pageEquipments);
|
||||||
|
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les modèles et marques uniques
|
||||||
|
_extractUniqueValues();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentProvider] Error loading equipments: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
|
||||||
|
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||||
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier d'abord le cache local
|
||||||
|
final cachedEquipments = <EquipmentModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in equipmentIds) {
|
||||||
|
final cached = _equipment.firstWhere(
|
||||||
|
(eq) => eq.id == id,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached.id.isNotEmpty) {
|
||||||
|
cachedEquipments.add(cached);
|
||||||
|
} else {
|
||||||
|
missingIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
|
// Si tous sont en cache, retourner directement
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return cachedEquipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les manquants depuis l'API
|
||||||
|
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
|
||||||
|
|
||||||
|
final loadedEquipments = equipmentsData.map((data) {
|
||||||
|
final id = data['id'] as String; // L'ID vient du backend
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Ajouter au cache
|
||||||
|
for (final eq in loadedEquipments) {
|
||||||
|
if (!_equipment.any((e) => e.id == eq.id)) {
|
||||||
|
_equipment.add(eq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
|
||||||
|
|
||||||
|
// Retourner tous les équipements (cache + chargés)
|
||||||
|
return [...cachedEquipments, ...loadedEquipments];
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentProvider] Error loading equipments by IDs: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extraire modèles et marques uniques
|
||||||
|
void _extractUniqueValues() {
|
||||||
|
final modelSet = <String>{};
|
||||||
|
final brandSet = <String>{};
|
||||||
|
|
||||||
|
for (final eq in _equipment) {
|
||||||
|
if (eq.model != null && eq.model!.isNotEmpty) {
|
||||||
|
modelSet.add(eq.model!);
|
||||||
|
}
|
||||||
|
if (eq.brand != null && eq.brand!.isNotEmpty) {
|
||||||
|
brandSet.add(eq.brand!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_models = modelSet.toList()..sort();
|
||||||
|
_brands = brandSet.toList()..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les équipements filtrés
|
||||||
|
List<EquipmentModel> get _filteredEquipment {
|
||||||
|
var filtered = _equipment;
|
||||||
|
|
||||||
|
if (_selectedCategory != null) {
|
||||||
|
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedStatus != null) {
|
||||||
|
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
|
||||||
|
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.where((eq) {
|
||||||
|
return eq.name.toLowerCase().contains(query) ||
|
||||||
|
eq.id.toLowerCase().contains(query) ||
|
||||||
|
(eq.model?.toLowerCase().contains(query) ?? false) ||
|
||||||
|
(eq.brand?.toLowerCase().contains(query) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION - Nouvelles méthodes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Active le mode pagination (pour la page de gestion)
|
||||||
|
void enablePagination() {
|
||||||
|
if (!_usePagination) {
|
||||||
|
_usePagination = true;
|
||||||
|
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode pagination (pour les autres pages)
|
||||||
|
void disablePagination() {
|
||||||
|
if (_usePagination) {
|
||||||
|
_usePagination = false;
|
||||||
|
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la première page (réinitialise tout)
|
||||||
|
Future<void> loadFirstPage() async {
|
||||||
|
DebugLog.info('[EquipmentProvider] Loading first page...');
|
||||||
|
|
||||||
|
_paginatedEquipment.clear();
|
||||||
|
_lastVisible = null;
|
||||||
|
_hasMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadNextPage();
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error loading first page', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la page suivante (scroll infini)
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMore) {
|
||||||
|
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 20,
|
||||||
|
startAfter: _lastVisible,
|
||||||
|
category: _selectedCategory?.name,
|
||||||
|
status: _selectedStatus?.name,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||||
|
.map((data) {
|
||||||
|
final map = data as Map<String, dynamic>;
|
||||||
|
final id = map['id'] as String; // L'ID vient du backend dans le JSON
|
||||||
|
return EquipmentModel.fromMap(map, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_paginatedEquipment.addAll(newEquipments);
|
||||||
|
_hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
|
||||||
|
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading models: $e');
|
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
||||||
rethrow;
|
_isLoadingMore = false;
|
||||||
}
|
_isLoading = false;
|
||||||
}
|
|
||||||
|
|
||||||
/// Charger toutes les marques uniques
|
|
||||||
Future<void> loadBrands() async {
|
|
||||||
try {
|
|
||||||
_brands = await _service.getAllBrands();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
|
||||||
print('Error loading brands: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charger les modèles filtrés par marque
|
/// Recharge en changeant de filtre ou recherche
|
||||||
Future<List<String>> loadModelsByBrand(String brand) async {
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de catégorie
|
||||||
|
void setSelectedCategory(EquipmentCategory? category) async {
|
||||||
|
if (_selectedCategory == category) return;
|
||||||
|
_selectedCategory = category;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de statut
|
||||||
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
|
_selectedStatus = status;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de modèle
|
||||||
|
void setSelectedModel(String? model) async {
|
||||||
|
if (_selectedModel == model) return;
|
||||||
|
_selectedModel = model;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialiser tous les filtres
|
||||||
|
void clearFilters() async {
|
||||||
|
_selectedCategory = null;
|
||||||
|
_selectedStatus = null;
|
||||||
|
_selectedModel = null;
|
||||||
|
_searchQuery = '';
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MÉTHODES COMPATIBILITÉ (pour ancien code)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Recharger les équipements (ancien système)
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream des équipements (pour compatibilité avec ancien code)
|
||||||
|
Stream<List<EquipmentModel>> get equipmentStream async* {
|
||||||
|
if (!_isInitialized && !_usePagination) {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
yield equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
return await _service.getModelsByBrand(brand);
|
await _dataService.deleteEquipment(equipmentId);
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading models by brand: $e');
|
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Ajouter un équipement
|
/// Ajouter un équipement
|
||||||
Future<void> addEquipment(EquipmentModel equipment) async {
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _service.createEquipment(equipment);
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
// Recharger les modèles si un nouveau modèle a été ajouté
|
await reload();
|
||||||
if (equipment.model != null && !_models.contains(equipment.model)) {
|
} else {
|
||||||
await loadModels();
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding equipment: $e');
|
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un équipement
|
/// Mettre à jour un équipement
|
||||||
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _service.updateEquipment(id, data);
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
// Recharger les modèles si le modèle a changé
|
await reload();
|
||||||
if (data.containsKey('model')) {
|
} else {
|
||||||
await loadModels();
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating equipment: $e');
|
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un équipement
|
/// Charger les marques
|
||||||
Future<void> deleteEquipment(String id) async {
|
Future<void> loadBrands() async {
|
||||||
try {
|
await ensureLoaded();
|
||||||
await _service.deleteEquipment(id);
|
_extractUniqueValues();
|
||||||
} catch (e) {
|
}
|
||||||
print('Error deleting equipment: $e');
|
|
||||||
rethrow;
|
/// Charger les modèles
|
||||||
|
Future<void> loadModels() async {
|
||||||
|
await ensureLoaded();
|
||||||
|
_extractUniqueValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les modèles d'une marque spécifique
|
||||||
|
Future<List<String>> loadModelsByBrand(String brand) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
|
||||||
|
.map((eq) => eq.model ?? '')
|
||||||
|
.where((model) => model.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les sous-catégories d'une catégorie spécifique
|
||||||
|
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.category == category)
|
||||||
|
.map((eq) => eq.subCategory ?? '')
|
||||||
|
.where((sub) => sub.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculer le statut réel d'un équipement (pour badge)
|
||||||
|
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
|
||||||
|
// Pour les consommables/câbles, vérifier le seuil critique
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) {
|
||||||
|
final availableQty = equipment.availableQuantity ?? 0;
|
||||||
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||||
|
|
||||||
|
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
|
||||||
|
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer un équipement par ID
|
// Sinon retourner le statut de base
|
||||||
Future<EquipmentModel?> getEquipmentById(String id) async {
|
return equipment.status;
|
||||||
try {
|
|
||||||
return await _service.getEquipmentById(id);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting equipment: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trouver des alternatives disponibles
|
|
||||||
Future<List<EquipmentModel>> findAlternatives(
|
|
||||||
String model,
|
|
||||||
DateTime startDate,
|
|
||||||
DateTime endDate,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
return await _service.findAlternatives(model, startDate, endDate);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error finding alternatives: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier la disponibilité d'un équipement
|
|
||||||
Future<List<String>> checkAvailability(
|
|
||||||
String equipmentId,
|
|
||||||
DateTime startDate,
|
|
||||||
DateTime endDate,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
return await _service.checkAvailability(equipmentId, startDate, endDate);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking availability: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mettre à jour le stock d'un consommable
|
|
||||||
Future<void> updateStock(String id, int quantityChange) async {
|
|
||||||
try {
|
|
||||||
await _service.updateStock(id, quantityChange);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier les stocks critiques
|
|
||||||
Future<void> checkCriticalStock() async {
|
|
||||||
try {
|
|
||||||
await _service.checkCriticalStock();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking critical stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Générer les données du QR code
|
|
||||||
String generateQRCodeData(String equipmentId) {
|
|
||||||
return _service.generateQRCodeData(equipmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier si un ID est unique
|
|
||||||
Future<bool> isIdUnique(String id) async {
|
|
||||||
try {
|
|
||||||
return await _service.isIdUnique(id);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking ID uniqueness: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === FILTRES ===
|
|
||||||
|
|
||||||
/// Définir la catégorie sélectionnée
|
|
||||||
void setSelectedCategory(EquipmentCategory? category) {
|
|
||||||
_selectedCategory = category;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir le statut sélectionné
|
|
||||||
void setSelectedStatus(EquipmentStatus? status) {
|
|
||||||
_selectedStatus = status;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir le modèle sélectionné
|
|
||||||
void setSelectedModel(String? model) {
|
|
||||||
_selectedModel = model;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir la recherche
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
_searchQuery = query;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Réinitialiser tous les filtres
|
|
||||||
void resetFilters() {
|
|
||||||
_selectedCategory = null;
|
|
||||||
_selectedStatus = null;
|
|
||||||
_selectedModel = null;
|
|
||||||
_searchQuery = '';
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +1,251 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/event_model.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
|
|
||||||
class EventProvider with ChangeNotifier {
|
class EventProvider with ChangeNotifier {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
List<EventModel> _events = [];
|
List<EventModel> _events = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
// Cache des utilisateurs chargés depuis getEvents
|
||||||
|
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||||
|
|
||||||
|
// Cache pour éviter les rechargements inutiles (ancien système)
|
||||||
|
DateTime? _lastLoadTime;
|
||||||
|
String? _lastUserId;
|
||||||
|
bool _lastCanViewAll = false;
|
||||||
|
|
||||||
|
// Nouveau: Cache par mois pour le lazy loading
|
||||||
|
Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
|
||||||
|
String? _currentMonth; // Mois actuellement affiché
|
||||||
|
|
||||||
List<EventModel> get events => _events;
|
List<EventModel> get events => _events;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
// Récupérer les événements pour un utilisateur spécifique
|
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
||||||
Future<void> loadUserEvents(String userId,
|
bool _shouldReload(String userId, bool canViewAllEvents) {
|
||||||
{bool canViewAllEvents = false}) async {
|
if (_lastLoadTime == null) return true;
|
||||||
|
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(_lastLoadTime!);
|
||||||
|
return difference.inSeconds > 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les événements d'un utilisateur via l'API
|
||||||
|
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
|
||||||
|
PerformanceMonitor.start('EventProvider.loadUserEvents');
|
||||||
|
|
||||||
|
// Éviter les rechargements inutiles
|
||||||
|
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
|
||||||
|
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print(
|
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
|
||||||
|
|
||||||
QuerySnapshot eventsSnapshot = await _firestore.collection('events').get();
|
PerformanceMonitor.start('EventProvider.getEvents_API');
|
||||||
print('Found ${eventsSnapshot.docs.length} events total');
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
|
PerformanceMonitor.end('EventProvider.getEvents_API');
|
||||||
|
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Stocker les utilisateurs dans le cache
|
||||||
|
_usersCache = usersData.map((key, value) =>
|
||||||
|
MapEntry(key, value as Map<String, dynamic>)
|
||||||
|
);
|
||||||
|
|
||||||
|
print('Found ${eventsData.length} events from API');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.parseEvents');
|
||||||
List<EventModel> allEvents = [];
|
List<EventModel> allEvents = [];
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
|
||||||
// Parser chaque événement individuellement pour éviter qu'une erreur interrompe tout
|
// Parser chaque événement
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
try {
|
try {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
final event = EventModel.fromMap(data, doc.id);
|
|
||||||
allEvents.add(event);
|
allEvents.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Failed to parse event ${doc.id}: $e');
|
print('Failed to parse event ${eventData['id']}: $e');
|
||||||
failedCount++;
|
failedCount++;
|
||||||
// Continue avec les autres événements au lieu d'arrêter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseEvents');
|
||||||
|
|
||||||
// Filtrage amélioré pour les utilisateurs non-admin
|
_events = allEvents;
|
||||||
if (canViewAllEvents) {
|
_lastLoadTime = DateTime.now();
|
||||||
_events = allEvents;
|
_lastUserId = userId;
|
||||||
print('Admin user: showing all ${_events.length} events');
|
_lastCanViewAll = canViewAllEvents;
|
||||||
} else {
|
|
||||||
// Créer la référence utilisateur correctement
|
|
||||||
final userDocRef = _firestore.collection('users').doc(userId);
|
|
||||||
|
|
||||||
_events = allEvents.where((event) {
|
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||||
// Vérifier si l'utilisateur est dans l'équipe de l'événement
|
|
||||||
bool isInWorkforce = event.workforce.any((workforceRef) {
|
|
||||||
return workforceRef.path == userDocRef.path;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isInWorkforce) {
|
|
||||||
print('Event ${event.name} includes user $userId');
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInWorkforce;
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading events: $e');
|
print('Error loading events: $e');
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_events = []; // S'assurer que la liste est vide en cas d'erreur
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un événement spécifique
|
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
Future<EventModel?> getEvent(String eventId) async {
|
Future<void> loadMonthEvents(String userId, int year, int month,
|
||||||
try {
|
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
||||||
final doc = await _firestore.collection('events').doc(eventId).get();
|
|
||||||
if (doc.exists) {
|
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||||
return EventModel.fromMap(doc.data()!, doc.id);
|
|
||||||
|
// Vérifier le cache
|
||||||
|
if (!forceReload && _eventsByMonth.containsKey(monthKey)) {
|
||||||
|
print('[EventProvider] Using cached events for $monthKey');
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_currentMonth = monthKey;
|
||||||
|
_events = _eventsByMonth[monthKey]!;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
print('[EventProvider] Loading events for month: $monthKey');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||||
|
final result = await _dataService.getEventsByMonth(
|
||||||
|
userId: userId,
|
||||||
|
year: year,
|
||||||
|
month: month
|
||||||
|
);
|
||||||
|
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
||||||
|
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
||||||
|
_usersCache.addAll(
|
||||||
|
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
|
||||||
|
);
|
||||||
|
|
||||||
|
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.parseMonthEvents');
|
||||||
|
List<EventModel> monthEvents = [];
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
// Parser les événements
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
monthEvents.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseMonthEvents');
|
||||||
|
|
||||||
|
// Stocker dans le cache par mois
|
||||||
|
_eventsByMonth[monthKey] = monthEvents;
|
||||||
|
|
||||||
|
// Mettre à jour _events et _currentMonth seulement si ce n'est pas un préchargement silencieux
|
||||||
|
if (!silent) {
|
||||||
|
_currentMonth = monthKey;
|
||||||
|
_events = monthEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les infos de cache global
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
|
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey (${failedCount} failed)');
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting event: $e');
|
print('[EventProvider] Error loading month events: $e');
|
||||||
|
if (!silent) {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un nouvel événement
|
/// Précharger les mois adjacents en arrière-plan
|
||||||
|
void preloadAdjacentMonths(String userId, int year, int month,
|
||||||
|
{bool canViewAllEvents = false}) {
|
||||||
|
|
||||||
|
// Mois précédent
|
||||||
|
final prevMonth = month == 1 ? 12 : month - 1;
|
||||||
|
final prevYear = month == 1 ? year - 1 : year;
|
||||||
|
|
||||||
|
// Mois suivant
|
||||||
|
final nextMonth = month == 12 ? 1 : month + 1;
|
||||||
|
final nextYear = month == 12 ? year + 1 : year;
|
||||||
|
|
||||||
|
print('[EventProvider] Preloading adjacent months...');
|
||||||
|
|
||||||
|
// Charger en arrière-plan (sans bloquer l'UI ni notifier)
|
||||||
|
Future.microtask(() async {
|
||||||
|
try {
|
||||||
|
await loadMonthEvents(userId, prevYear, prevMonth,
|
||||||
|
canViewAllEvents: canViewAllEvents, silent: true);
|
||||||
|
await loadMonthEvents(userId, nextYear, nextMonth,
|
||||||
|
canViewAllEvents: canViewAllEvents, silent: true);
|
||||||
|
print('[EventProvider] Adjacent months preloaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventProvider] Error preloading adjacent months: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
|
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
|
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer un événement spécifique par ID
|
||||||
|
EventModel? getEventById(String eventId) {
|
||||||
|
try {
|
||||||
|
return _events.firstWhere((event) => event.id == eventId);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un nouvel événement
|
||||||
Future<void> addEvent(EventModel event) async {
|
Future<void> addEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
final docRef = await _firestore.collection('events').add(event.toMap());
|
// Ajouter l'événement localement dans _events
|
||||||
final newEvent = EventModel.fromMap(event.toMap(), docRef.id);
|
_events.add(event);
|
||||||
_events.add(newEvent);
|
|
||||||
|
// Ajouter dans le cache par mois
|
||||||
|
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
if (_eventsByMonth.containsKey(monthKey)) {
|
||||||
|
_eventsByMonth[monthKey]!.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding event: $e');
|
print('Error adding event: $e');
|
||||||
@@ -100,13 +253,37 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour un événement
|
/// Mettre à jour un événement
|
||||||
Future<void> updateEvent(EventModel event) async {
|
Future<void> updateEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(event.id).update(event.toMap());
|
// Mise à jour dans _events
|
||||||
final index = _events.indexWhere((e) => e.id == event.id);
|
final index = _events.indexWhere((e) => e.id == event.id);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final oldEvent = _events[index];
|
||||||
_events[index] = event;
|
_events[index] = event;
|
||||||
|
|
||||||
|
// Mettre à jour dans le cache par mois
|
||||||
|
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
|
||||||
|
if (oldMonthKey != newMonthKey) {
|
||||||
|
if (_eventsByMonth.containsKey(oldMonthKey)) {
|
||||||
|
_eventsByMonth[oldMonthKey]!.removeWhere((e) => e.id == event.id);
|
||||||
|
}
|
||||||
|
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||||
|
_eventsByMonth[newMonthKey]!.add(event);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Même mois, juste mettre à jour
|
||||||
|
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||||
|
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
|
||||||
|
if (monthIndex != -1) {
|
||||||
|
_eventsByMonth[newMonthKey]![monthIndex] = event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -115,11 +292,23 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un événement
|
/// Supprimer un événement
|
||||||
Future<void> deleteEvent(String eventId) async {
|
Future<void> deleteEvent(String eventId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(eventId).delete();
|
await _dataService.deleteEvent(eventId);
|
||||||
|
|
||||||
|
// Trouver l'événement pour obtenir sa date avant de le supprimer
|
||||||
|
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
|
||||||
|
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// Supprimer de _events
|
||||||
_events.removeWhere((event) => event.id == eventId);
|
_events.removeWhere((event) => event.id == eventId);
|
||||||
|
|
||||||
|
// Supprimer du cache par mois
|
||||||
|
if (_eventsByMonth.containsKey(monthKey)) {
|
||||||
|
_eventsByMonth[monthKey]!.removeWhere((event) => event.id == eventId);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting event: $e');
|
print('Error deleting event: $e');
|
||||||
@@ -127,9 +316,49 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vider la liste des événements
|
/// Récupérer les données d'un utilisateur depuis le cache
|
||||||
|
Map<String, dynamic>? getUserFromCache(String uid) {
|
||||||
|
return _usersCache[uid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les utilisateurs de la workforce d'un événement
|
||||||
|
List<Map<String, dynamic>> getWorkforceUsers(EventModel event) {
|
||||||
|
final users = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (final dynamic userRef in event.workforce) {
|
||||||
|
try {
|
||||||
|
String? uid;
|
||||||
|
|
||||||
|
// Tenter d'extraire l'UID
|
||||||
|
if (userRef is String) {
|
||||||
|
uid = userRef;
|
||||||
|
} else {
|
||||||
|
// Essayer d'extraire l'ID si c'est une DocumentReference
|
||||||
|
final ref = userRef as DocumentReference?;
|
||||||
|
uid = ref?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uid != null) {
|
||||||
|
final userData = getUserFromCache(uid);
|
||||||
|
if (userData != null) {
|
||||||
|
users.add(userData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorer les références invalides
|
||||||
|
print('Skipping invalid workforce reference: $userRef');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vider la liste des événements
|
||||||
void clearEvents() {
|
void clearEvents() {
|
||||||
_events = [];
|
_events = [];
|
||||||
|
_lastLoadTime = null;
|
||||||
|
_lastUserId = null;
|
||||||
|
_lastCanViewAll = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
import '../models/role_model.dart';
|
import '../models/role_model.dart';
|
||||||
|
import '../models/notification_preferences_model.dart';
|
||||||
import '../utils/firebase_storage_manager.dart';
|
import '../utils/firebase_storage_manager.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
import '../services/data_service.dart';
|
||||||
|
import '../utils/performance_monitor.dart';
|
||||||
|
|
||||||
class LocalUserProvider with ChangeNotifier {
|
class LocalUserProvider with ChangeNotifier {
|
||||||
UserModel? _currentUser;
|
UserModel? _currentUser;
|
||||||
RoleModel? _currentRole;
|
RoleModel? _currentRole;
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
||||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
|
bool _isLoadingUserData = false;
|
||||||
|
DateTime? _lastUserDataLoad;
|
||||||
|
|
||||||
UserModel? get currentUser => _currentUser;
|
UserModel? get currentUser => _currentUser;
|
||||||
String? get uid => _currentUser?.uid;
|
String? get uid => _currentUser?.uid;
|
||||||
@@ -23,65 +29,76 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
String? get phoneNumber => _currentUser?.phoneNumber;
|
String? get phoneNumber => _currentUser?.phoneNumber;
|
||||||
RoleModel? get currentRole => _currentRole;
|
RoleModel? get currentRole => _currentRole;
|
||||||
List<String> get permissions => _currentRole?.permissions ?? [];
|
List<String> get permissions => _currentRole?.permissions ?? [];
|
||||||
|
bool get isLoadingUserData => _isLoadingUserData;
|
||||||
|
|
||||||
/// Charge les données de l'utilisateur actuel
|
/// Vérifie si les données utilisateur doivent être rechargées
|
||||||
Future<void> loadUserData() async {
|
bool _shouldReloadUserData() {
|
||||||
|
if (_currentUser == null) return true;
|
||||||
|
if (_lastUserDataLoad == null) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(_lastUserDataLoad!);
|
||||||
|
return difference.inMinutes > 5; // Cache de 5 minutes pour les données utilisateur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||||
|
Future<void> loadUserData({bool forceReload = false}) async {
|
||||||
if (_auth.currentUser == null) {
|
if (_auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Éviter les rechargements inutiles
|
||||||
|
if (!forceReload && !_shouldReloadUserData()) {
|
||||||
|
print('Using cached user data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éviter les appels simultanés
|
||||||
|
if (_isLoadingUserData) {
|
||||||
|
print('User data already loading, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoadingUserData = true;
|
||||||
|
PerformanceMonitor.start('LocalUserProvider.loadUserData');
|
||||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
DocumentSnapshot userDoc = await _firestore
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
.collection('users')
|
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
||||||
.doc(_auth.currentUser!.uid)
|
final result = await apiService.call('getCurrentUser', {});
|
||||||
.get();
|
PerformanceMonitor.end('LocalUserProvider.getCurrentUser_API');
|
||||||
|
|
||||||
if (userDoc.exists) {
|
final userData = result['user'] as Map<String, dynamic>;
|
||||||
print('User document found in Firestore');
|
|
||||||
final userData = userDoc.data() as Map<String, dynamic>;
|
|
||||||
print('User data: $userData');
|
|
||||||
|
|
||||||
// Si le document n'a pas d'UID, l'ajouter
|
print('User data loaded from API: ${userData['uid']}');
|
||||||
if (!userData.containsKey('uid')) {
|
|
||||||
await userDoc.reference.update({'uid': _auth.currentUser!.uid});
|
|
||||||
userData['uid'] = _auth.currentUser!.uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(UserModel.fromMap(userData, userDoc.id));
|
// Extraire le rôle
|
||||||
print('User data loaded successfully');
|
final roleData = userData['role'] as Map<String, dynamic>?;
|
||||||
await loadRole();
|
if (roleData != null) {
|
||||||
} else {
|
_currentRole = RoleModel.fromMap(roleData, roleData['id'] as String);
|
||||||
print('No user document found in Firestore');
|
|
||||||
// Créer un document utilisateur par défaut
|
|
||||||
final defaultUser = UserModel(
|
|
||||||
uid: _auth.currentUser!.uid,
|
|
||||||
email: _auth.currentUser!.email ?? '',
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
role: 'USER',
|
|
||||||
phoneNumber: '',
|
|
||||||
profilePhotoUrl: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _firestore.collection('users').doc(_auth.currentUser!.uid).set({
|
|
||||||
'uid': _auth.currentUser!.uid,
|
|
||||||
'email': _auth.currentUser!.email,
|
|
||||||
'firstName': '',
|
|
||||||
'lastName': '',
|
|
||||||
'role': 'USER',
|
|
||||||
'phoneNumber': '',
|
|
||||||
'profilePhotoUrl': '',
|
|
||||||
'createdAt': FieldValue.serverTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setUser(defaultUser);
|
|
||||||
print('Default user document created');
|
|
||||||
await loadRole();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Créer le UserModel
|
||||||
|
_currentUser = UserModel(
|
||||||
|
uid: userData['uid'] as String,
|
||||||
|
email: userData['email'] as String? ?? '',
|
||||||
|
firstName: userData['firstName'] as String? ?? '',
|
||||||
|
lastName: userData['lastName'] as String? ?? '',
|
||||||
|
role: roleData?['id'] as String? ?? 'USER',
|
||||||
|
phoneNumber: userData['phoneNumber'] as String? ?? '',
|
||||||
|
profilePhotoUrl: userData['profilePhotoUrl'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
print('User data loaded successfully');
|
||||||
|
_lastUserDataLoad = DateTime.now();
|
||||||
|
_isLoadingUserData = false;
|
||||||
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading user data: $e');
|
print('Error loading user data: $e');
|
||||||
|
_isLoadingUserData = false;
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,28 +112,57 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
/// Efface les données utilisateur
|
/// Efface les données utilisateur
|
||||||
void clearUser() {
|
void clearUser() {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
|
_currentRole = null;
|
||||||
|
_lastUserDataLoad = null;
|
||||||
|
_isLoadingUserData = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mise à jour des informations utilisateur
|
/// Mise à jour des informations utilisateur via Cloud Function
|
||||||
Future<void> updateUserData(
|
Future<void> updateUserData({
|
||||||
{String? firstName, String? lastName, String? phoneNumber}) async {
|
String? firstName,
|
||||||
|
String? lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
}) async {
|
||||||
if (_currentUser == null) return;
|
if (_currentUser == null) return;
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(_currentUser!.uid).set({
|
await _dataService.updateUser(
|
||||||
'firstName': firstName ?? _currentUser!.firstName,
|
_currentUser!.uid,
|
||||||
'lastName': lastName ?? _currentUser!.lastName,
|
{
|
||||||
'phone': phoneNumber ?? _currentUser!.phoneNumber,
|
'firstName': firstName ?? _currentUser!.firstName,
|
||||||
}, SetOptions(merge: true));
|
'lastName': lastName ?? _currentUser!.lastName,
|
||||||
|
'phoneNumber': phoneNumber ?? _currentUser!.phoneNumber,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
_currentUser = _currentUser!.copyWith(
|
_currentUser = _currentUser!.copyWith(
|
||||||
firstName: firstName ?? _currentUser!.firstName,
|
firstName: firstName,
|
||||||
lastName: lastName ?? _currentUser!.lastName,
|
lastName: lastName,
|
||||||
phoneNumber: phoneNumber ?? _currentUser!.phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur mise à jour utilisateur : $e');
|
debugPrint('Erreur mise à jour utilisateur : $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour des préférences de notifications
|
||||||
|
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
|
||||||
|
if (_currentUser == null) return;
|
||||||
|
try {
|
||||||
|
await _dataService.updateUser(
|
||||||
|
_currentUser!.uid,
|
||||||
|
{
|
||||||
|
'notificationPreferences': preferences.toMap(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Erreur mise à jour préférences notifications : $e');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,16 +175,18 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
uid: _currentUser!.uid,
|
uid: _currentUser!.uid,
|
||||||
);
|
);
|
||||||
if (newProfilePhotoUrl != null) {
|
if (newProfilePhotoUrl != null) {
|
||||||
_firestore
|
// Mettre à jour via Cloud Function
|
||||||
.collection('users')
|
await _dataService.updateUser(
|
||||||
.doc(_currentUser!.uid)
|
_currentUser!.uid,
|
||||||
.update({'profilePhotoUrl': newProfilePhotoUrl});
|
{'profilePhotoUrl': newProfilePhotoUrl},
|
||||||
_currentUser =
|
);
|
||||||
_currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
|
||||||
|
_currentUser = _currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur mise à jour photo de profil : $e');
|
debugPrint('Erreur mise à jour photo de profil : $e');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +196,8 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
||||||
email: email, password: password);
|
email: email, password: password);
|
||||||
await loadUserData();
|
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
||||||
|
// pour ne pas bloquer la navigation
|
||||||
return userCredential;
|
return userCredential;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw FirebaseAuthException(code: 'login-failed', message: e.toString());
|
throw FirebaseAuthException(code: 'login-failed', message: e.toString());
|
||||||
@@ -161,23 +210,20 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
clearUser();
|
clearUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadRole() async {
|
/// Vérifie si l'utilisateur a une permission spécifique
|
||||||
if (_currentUser == null) return;
|
|
||||||
final roleId = _currentUser!.role;
|
|
||||||
if (roleId.isEmpty) return;
|
|
||||||
try {
|
|
||||||
final doc = await _firestore.collection('roles').doc(roleId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
_currentRole =
|
|
||||||
RoleModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading role: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasPermission(String permission) {
|
bool hasPermission(String permission) {
|
||||||
return _currentRole?.permissions.contains(permission) ?? false;
|
return _currentRole?.permissions.contains(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.every((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.any((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,57 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
import 'package:em2rp/services/maintenance_service.dart';
|
import 'package:em2rp/services/maintenance_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class MaintenanceProvider extends ChangeNotifier {
|
class MaintenanceProvider extends ChangeNotifier {
|
||||||
final MaintenanceService _service = MaintenanceService();
|
final MaintenanceService _service = MaintenanceService();
|
||||||
|
|
||||||
List<MaintenanceModel> _maintenances = [];
|
List<MaintenanceModel> _maintenances = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<MaintenanceModel> get maintenances => _maintenances;
|
List<MaintenanceModel> get maintenances => _maintenances;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
/// Stream des maintenances pour un équipement spécifique
|
/// Charger toutes les maintenances
|
||||||
Stream<List<MaintenanceModel>> getMaintenancesStream(String equipmentId) {
|
Future<void> loadMaintenances({String? equipmentId}) async {
|
||||||
return _service.getMaintenances(equipmentId);
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (equipmentId != null) {
|
||||||
|
_maintenances = await _service.getMaintenancesByEquipment(equipmentId);
|
||||||
|
} else {
|
||||||
|
_maintenances = await _service.getAllMaintenances();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[MaintenanceProvider] Error loading maintenances', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream de toutes les maintenances
|
/// Récupérer les maintenances pour un équipement spécifique
|
||||||
Stream<List<MaintenanceModel>> get allMaintenancesStream {
|
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||||
return _service.getAllMaintenances();
|
return await _service.getMaintenancesByEquipment(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer toutes les maintenances
|
||||||
|
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||||
|
return await _service.getAllMaintenances();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une nouvelle maintenance
|
/// Créer une nouvelle maintenance
|
||||||
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||||
try {
|
try {
|
||||||
await _service.createMaintenance(maintenance);
|
await _service.createMaintenance(maintenance);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après création
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error creating maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,9 +60,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
await _service.updateMaintenance(id, data);
|
await _service.updateMaintenance(id, data);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après mise à jour
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error updating maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +71,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
Future<void> deleteMaintenance(String id) async {
|
Future<void> deleteMaintenance(String id) async {
|
||||||
try {
|
try {
|
||||||
await _service.deleteMaintenance(id);
|
await _service.deleteMaintenance(id);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après suppression
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error deleting maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +83,7 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
return await _service.getMaintenanceById(id);
|
return await _service.getMaintenanceById(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error getting maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,9 +96,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
||||||
notifyListeners();
|
await loadMaintenances(); // Recharger après complétion
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error completing maintenance: $e');
|
DebugLog.error('[MaintenanceProvider] Error completing maintenance', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +108,13 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _service.checkUpcomingMaintenances();
|
await _service.checkUpcomingMaintenances();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking upcoming maintenances: $e');
|
DebugLog.error('[MaintenanceProvider] Error checking upcoming maintenances', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les maintenances en retard
|
/// Récupérer les maintenances en retard
|
||||||
List<MaintenanceModel> get overdueMaintances {
|
List<MaintenanceModel> get overdueMaintenances {
|
||||||
return _maintenances.where((m) => m.isOverdue).toList();
|
return _maintenances.where((m) => m.isOverdue).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +127,12 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
List<MaintenanceModel> get upcomingMaintenances {
|
List<MaintenanceModel> get upcomingMaintenances {
|
||||||
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Obtenir les maintenances pour un équipement spécifique (depuis le cache local)
|
||||||
|
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
||||||
|
return _maintenances.where((m) =>
|
||||||
|
m.equipmentIds.contains(equipmentId)
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
em2rp/lib/providers/maintenance_provider_new.dart
Normal file
51
em2rp/lib/providers/maintenance_provider_new.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
class MaintenanceProvider extends ChangeNotifier {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
List<MaintenanceModel> _maintenances = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
List<MaintenanceModel> get maintenances => _maintenances;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Charger toutes les maintenances via l'API
|
||||||
|
Future<void> loadMaintenances({String? equipmentId}) async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final maintenancesData = await _dataService.getMaintenances(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
_maintenances = maintenancesData.map((data) {
|
||||||
|
return MaintenanceModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading maintenances: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharger les maintenances
|
||||||
|
Future<void> refresh({String? equipmentId}) async {
|
||||||
|
await loadMaintenances(equipmentId: equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les maintenances pour un équipement spécifique
|
||||||
|
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
||||||
|
return _maintenances.where((m) =>
|
||||||
|
m.equipmentIds.contains(equipmentId)
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,54 +1,54 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/user_model.dart';
|
|
||||||
import '../services/user_service.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class UsersProvider with ChangeNotifier {
|
class UsersProvider with ChangeNotifier {
|
||||||
final UserService _userService;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
|
||||||
List<UserModel> _users = [];
|
List<UserModel> _users = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
List<UserModel> get users => _users;
|
List<UserModel> get users => _users;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
UsersProvider(this._userService);
|
/// Récupération de tous les utilisateurs via l'API
|
||||||
|
|
||||||
/// Récupération de tous les utilisateurs
|
|
||||||
Future<void> fetchUsers() async {
|
Future<void> fetchUsers() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final snapshot = await _firestore.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
_users = snapshot.docs
|
_users = usersData.map((data) {
|
||||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
return UserModel.fromMap(data, data['id'] as String);
|
||||||
.toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching users: $e');
|
print('Error fetching users: $e');
|
||||||
|
_users = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mise à jour d'un utilisateur
|
/// Recharger les utilisateurs
|
||||||
Future<void> updateUser(UserModel user, {String? roleId}) async {
|
Future<void> refresh() async {
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir un utilisateur par ID
|
||||||
|
UserModel? getUserById(String uid) {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(user.uid).update({
|
return _users.firstWhere((u) => u.uid == uid);
|
||||||
'firstName': user.firstName,
|
} catch (e) {
|
||||||
'lastName': user.lastName,
|
return null;
|
||||||
'email': user.email,
|
}
|
||||||
'phoneNumber': user.phoneNumber,
|
}
|
||||||
'role': roleId != null
|
|
||||||
? _firestore.collection('roles').doc(roleId)
|
/// Mettre à jour un utilisateur
|
||||||
: user.role,
|
Future<void> updateUser(UserModel user) async {
|
||||||
'profilePhotoUrl': user.profilePhotoUrl,
|
try {
|
||||||
});
|
await _dataService.updateUser(user.uid, user.toMap());
|
||||||
|
|
||||||
final index = _users.indexWhere((u) => u.uid == user.uid);
|
final index = _users.indexWhere((u) => u.uid == user.uid);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
@@ -61,10 +61,10 @@ class UsersProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suppression d'un utilisateur
|
/// Suppression d'un utilisateur via Cloud Function
|
||||||
Future<void> deleteUser(String uid) async {
|
Future<void> deleteUser(String uid) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(uid).delete();
|
await _dataService.deleteUser(uid);
|
||||||
_users.removeWhere((user) => user.uid == uid);
|
_users.removeWhere((user) => user.uid == uid);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -73,97 +73,44 @@ class UsersProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Réinitialisation du mot de passe
|
/// Créer un utilisateur avec invitation par email
|
||||||
Future<void> resetPassword(String email) async {
|
Future<void> createUserWithEmailInvite({
|
||||||
await _userService.resetPassword(email);
|
required String email,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
required String roleId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('Creating user with email invite: $email');
|
||||||
|
|
||||||
|
// Appeler la Cloud Function pour créer l'utilisateur
|
||||||
|
await _dataService.createUserWithInvite(
|
||||||
|
email: email,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
phoneNumber: phoneNumber,
|
||||||
|
roleId: roleId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recharger la liste des utilisateurs
|
||||||
|
await fetchUsers();
|
||||||
|
|
||||||
|
print('User created successfully: $email');
|
||||||
|
} catch (e) {
|
||||||
|
print('Error creating user with email invite: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createUserWithEmailInvite(BuildContext context, UserModel user,
|
/// Réinitialisation du mot de passe
|
||||||
{String? roleId}) async {
|
Future<void> resetPassword(String email) async {
|
||||||
String? authUid;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Vérifier l'état de l'authentification
|
// Firebase Auth reste OK (ce n'est pas Firestore)
|
||||||
final currentUser = _auth.currentUser;
|
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
||||||
print('Current user: ${currentUser?.email}');
|
print('Email de réinitialisation envoyé à $email');
|
||||||
|
|
||||||
if (currentUser == null) {
|
|
||||||
throw Exception('Aucun utilisateur connecté');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier la permission via le provider
|
|
||||||
final localUserProvider =
|
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
if (!localUserProvider.hasPermission('add_user')) {
|
|
||||||
throw Exception(
|
|
||||||
'Vous n\'avez pas la permission de créer des utilisateurs');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Créer l'utilisateur dans Firebase Authentication
|
|
||||||
final userCredential = await _auth.createUserWithEmailAndPassword(
|
|
||||||
email: user.email,
|
|
||||||
password: 'TemporaryPassword123!', // Mot de passe temporaire
|
|
||||||
);
|
|
||||||
|
|
||||||
authUid = userCredential.user!.uid;
|
|
||||||
print('User created in Auth with UID: $authUid');
|
|
||||||
|
|
||||||
// Créer le document dans Firestore avec l'UID de Auth comme ID
|
|
||||||
await _firestore.collection('users').doc(authUid).set({
|
|
||||||
'uid': authUid,
|
|
||||||
'firstName': user.firstName,
|
|
||||||
'lastName': user.lastName,
|
|
||||||
'email': user.email,
|
|
||||||
'phoneNumber': user.phoneNumber,
|
|
||||||
'role': roleId != null
|
|
||||||
? _firestore.collection('roles').doc(roleId)
|
|
||||||
: user.role,
|
|
||||||
'profilePhotoUrl': user.profilePhotoUrl,
|
|
||||||
'createdAt': FieldValue.serverTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
print('User document created in Firestore with Auth UID');
|
|
||||||
|
|
||||||
// Envoyer un email de réinitialisation de mot de passe
|
|
||||||
await _auth.sendPasswordResetEmail(
|
|
||||||
email: user.email,
|
|
||||||
actionCodeSettings: ActionCodeSettings(
|
|
||||||
url: 'http://app.em2events.fr/finishSignUp?email=${user.email}',
|
|
||||||
handleCodeInApp: true,
|
|
||||||
androidPackageName: 'com.em2rp.app',
|
|
||||||
androidInstallApp: true,
|
|
||||||
androidMinimumVersion: '12',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
print('Password reset email sent');
|
|
||||||
|
|
||||||
// Ajouter l'utilisateur à la liste locale
|
|
||||||
final newUser = UserModel(
|
|
||||||
uid: authUid,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
email: user.email,
|
|
||||||
phoneNumber: user.phoneNumber,
|
|
||||||
role: roleId ?? user.role,
|
|
||||||
profilePhotoUrl: user.profilePhotoUrl,
|
|
||||||
);
|
|
||||||
_users.add(newUser);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
// En cas d'erreur, supprimer l'utilisateur Auth si créé
|
|
||||||
if (authUid != null) {
|
|
||||||
try {
|
|
||||||
await _auth.currentUser?.delete();
|
|
||||||
} catch (deleteError) {
|
|
||||||
print('Warning: Could not delete Auth user: $deleteError');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating user: $e');
|
print('Error reset password: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
em2rp/lib/services/alert_service.dart
Normal file
255
em2rp/lib/services/alert_service.dart
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import '../models/alert_model.dart';
|
||||||
|
import '../utils/debug_log.dart';
|
||||||
|
import 'api_service.dart' show FirebaseFunctionsApiService;
|
||||||
|
/// Service de gestion des alertes
|
||||||
|
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
|
||||||
|
/// Toute la logique métier est gérée côté backend
|
||||||
|
class AlertService {
|
||||||
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
|
|
||||||
|
/// Stream des alertes pour l'utilisateur connecté
|
||||||
|
Stream<List<AlertModel>> getAlertsStream() {
|
||||||
|
final user = _auth.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
DebugLog.info('[AlertService] Pas d\'utilisateur connecté');
|
||||||
|
return Stream.value([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}');
|
||||||
|
|
||||||
|
return _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: user.uid)
|
||||||
|
.where('status', isEqualTo: 'ACTIVE')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
final alerts = snapshot.docs
|
||||||
|
.map((doc) => AlertModel.fromFirestore(doc))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
DebugLog.info('[AlertService] ${alerts.length} alertes actives');
|
||||||
|
return alerts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les alertes non lues
|
||||||
|
Future<List<AlertModel>> getUnreadAlerts() async {
|
||||||
|
final user = _auth.currentUser;
|
||||||
|
if (user == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: user.uid)
|
||||||
|
.where('isRead', isEqualTo: false)
|
||||||
|
.where('status', isEqualTo: 'ACTIVE')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return snapshot.docs
|
||||||
|
.map((doc) => AlertModel.fromFirestore(doc))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque une alerte comme lue
|
||||||
|
Future<void> markAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _firestore.collection('alerts').doc(alertId).update({
|
||||||
|
'isRead': true,
|
||||||
|
'readAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
DebugLog.info('[AlertService] Alerte $alertId marquée comme lue');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur marquage alerte', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque toutes les alertes comme lues
|
||||||
|
Future<void> markAllAsRead() async {
|
||||||
|
final user = _auth.currentUser;
|
||||||
|
if (user == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: user.uid)
|
||||||
|
.where('isRead', isEqualTo: false)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final batch = _firestore.batch();
|
||||||
|
for (var doc in snapshot.docs) {
|
||||||
|
batch.update(doc.reference, {
|
||||||
|
'isRead': true,
|
||||||
|
'readAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur marquage alertes', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archive une alerte
|
||||||
|
Future<void> archiveAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _firestore.collection('alerts').doc(alertId).update({
|
||||||
|
'status': 'ARCHIVED',
|
||||||
|
'archivedAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
DebugLog.info('[AlertService] Alerte $alertId archivée');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur archivage alerte', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une alerte manuelle (appelée par l'utilisateur)
|
||||||
|
/// Cette méthode appelle la Cloud Function createAlert
|
||||||
|
Future<String> createManualAlert({
|
||||||
|
required AlertType type,
|
||||||
|
required AlertSeverity severity,
|
||||||
|
required String message,
|
||||||
|
String? title,
|
||||||
|
String? equipmentId,
|
||||||
|
String? eventId,
|
||||||
|
String? actionUrl,
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ===');
|
||||||
|
DebugLog.info('[AlertService] Type: $type');
|
||||||
|
DebugLog.info('[AlertService] Severity: $severity');
|
||||||
|
|
||||||
|
final apiService = FirebaseFunctionsApiService();
|
||||||
|
final result = await apiService.call(
|
||||||
|
'createAlert',
|
||||||
|
{
|
||||||
|
'type': alertTypeToString(type),
|
||||||
|
'severity': severity.name.toUpperCase(),
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'eventId': eventId,
|
||||||
|
'actionUrl': actionUrl,
|
||||||
|
'metadata': metadata ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final alertId = result['alertId'] as String;
|
||||||
|
DebugLog.info('[AlertService] ✓ Alerte créée: $alertId');
|
||||||
|
|
||||||
|
return alertId;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
DebugLog.error('[AlertService] ❌ Erreur création alerte', e);
|
||||||
|
DebugLog.error('[AlertService] Stack', stackTrace);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream des alertes pour un utilisateur spécifique
|
||||||
|
Stream<List<AlertModel>> alertsStreamForUser(String userId) {
|
||||||
|
return _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: userId)
|
||||||
|
.where('status', isEqualTo: 'ACTIVE')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) => snapshot.docs
|
||||||
|
.map((doc) => AlertModel.fromFirestore(doc))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les alertes pour un utilisateur
|
||||||
|
Future<List<AlertModel>> getAlertsForUser(String userId) async {
|
||||||
|
try {
|
||||||
|
final snapshot = await _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: userId)
|
||||||
|
.where('status', isEqualTo: 'ACTIVE')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return snapshot.docs
|
||||||
|
.map((doc) => AlertModel.fromFirestore(doc))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream du nombre d'alertes non lues pour un utilisateur
|
||||||
|
Stream<int> unreadCountStreamForUser(String userId) {
|
||||||
|
return _firestore
|
||||||
|
.collection('alerts')
|
||||||
|
.where('assignedTo', arrayContains: userId)
|
||||||
|
.where('isRead', isEqualTo: false)
|
||||||
|
.where('status', isEqualTo: 'ACTIVE')
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) => snapshot.docs.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une alerte
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _firestore.collection('alerts').doc(alertId).delete();
|
||||||
|
DebugLog.info('[AlertService] Alerte $alertId supprimée');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AlertService] Erreur suppression alerte', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une alerte de création d'événement
|
||||||
|
Future<void> createEventCreatedAlert({
|
||||||
|
required String eventId,
|
||||||
|
required String eventName,
|
||||||
|
required DateTime eventDate,
|
||||||
|
}) async {
|
||||||
|
await createManualAlert(
|
||||||
|
type: AlertType.eventCreated,
|
||||||
|
severity: AlertSeverity.info,
|
||||||
|
message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}',
|
||||||
|
eventId: eventId,
|
||||||
|
metadata: {
|
||||||
|
'eventName': eventName,
|
||||||
|
'eventDate': eventDate.toIso8601String(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une alerte de modification d'événement
|
||||||
|
Future<void> createEventModifiedAlert({
|
||||||
|
required String eventId,
|
||||||
|
required String eventName,
|
||||||
|
required String modification,
|
||||||
|
}) async {
|
||||||
|
await createManualAlert(
|
||||||
|
type: AlertType.eventModified,
|
||||||
|
severity: AlertSeverity.info,
|
||||||
|
message: 'Événement "$eventName" modifié: $modification',
|
||||||
|
eventId: eventId,
|
||||||
|
metadata: {
|
||||||
|
'eventName': eventName,
|
||||||
|
'modification': modification,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return '${date.day}/${date.month}/${date.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
360
em2rp/lib/services/api_service.dart
Normal file
360
em2rp/lib/services/api_service.dart
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:em2rp/config/api_config.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Interface abstraite pour les opérations API
|
||||||
|
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
|
||||||
|
abstract class ApiService {
|
||||||
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data);
|
||||||
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params});
|
||||||
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data);
|
||||||
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data);
|
||||||
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implémentation pour Firebase Cloud Functions
|
||||||
|
class FirebaseFunctionsApiService implements ApiService {
|
||||||
|
// URL de base - gérée par ApiConfig
|
||||||
|
String get _baseUrl => ApiConfig.baseUrl;
|
||||||
|
|
||||||
|
/// Récupère le token d'authentification Firebase
|
||||||
|
Future<String?> _getAuthToken() async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) return null;
|
||||||
|
return await user.getIdToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headers par défaut avec authentification
|
||||||
|
Future<Map<String, String>> _getHeaders() async {
|
||||||
|
final token = await _getAuthToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit récursivement TOUT en types JSON standards (String, num, bool, List, Map)
|
||||||
|
/// Garantit que toutes les Maps sont des Map<String, dynamic> littérales
|
||||||
|
dynamic _toJsonSafe(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
|
||||||
|
// Types primitifs JSON-safe
|
||||||
|
if (value is String || value is num || value is bool) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types Firestore
|
||||||
|
if (value is Timestamp) {
|
||||||
|
return value.toDate().toIso8601String();
|
||||||
|
}
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value.toIso8601String();
|
||||||
|
}
|
||||||
|
if (value is DocumentReference) {
|
||||||
|
return value.path;
|
||||||
|
}
|
||||||
|
if (value is GeoPoint) {
|
||||||
|
// Créer une Map littérale explicite
|
||||||
|
return <String, dynamic>{
|
||||||
|
'latitude': value.latitude,
|
||||||
|
'longitude': value.longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listes - créer une nouvelle List littérale
|
||||||
|
if (value is List) {
|
||||||
|
final result = <dynamic>[];
|
||||||
|
for (final item in value) {
|
||||||
|
result.add(_toJsonSafe(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps - créer une nouvelle Map littérale explicite
|
||||||
|
if (value is Map) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
value.forEach((k, v) {
|
||||||
|
final key = k.toString();
|
||||||
|
final convertedValue = _toJsonSafe(v);
|
||||||
|
result[key] = convertedValue;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type non supporté - retourner en String
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prépare les données pour jsonEncode en faisant un double passage
|
||||||
|
Map<String, dynamic> _prepareForJson(Map<String, dynamic> data) {
|
||||||
|
try {
|
||||||
|
// Premier passage : convertir tous les types Firestore
|
||||||
|
final safeData = _toJsonSafe(data);
|
||||||
|
|
||||||
|
// Deuxième passage : encoder puis décoder pour forcer la normalisation
|
||||||
|
// Cela garantit que tout est 100% compatible JSON et élimine tous les _JsonMap
|
||||||
|
final jsonString = jsonEncode(safeData);
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
|
||||||
|
// Force le type Map<String, dynamic>
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback - ne devrait jamais arriver
|
||||||
|
return Map<String, dynamic>.from(safeData as Map);
|
||||||
|
} catch (e) {
|
||||||
|
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
|
||||||
|
DebugLog.error('[API] Error in _prepareForJson', e);
|
||||||
|
DebugLog.info('[API] Trying manual deep copy...');
|
||||||
|
return _deepCopyMap(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie profonde manuelle d'une Map pour éviter les _JsonMap
|
||||||
|
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> source) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
source.forEach((key, value) {
|
||||||
|
if (value is Map) {
|
||||||
|
result[key] = _deepCopyMap(Map<String, dynamic>.from(value));
|
||||||
|
} else if (value is List) {
|
||||||
|
result[key] = _deepCopyList(value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie profonde manuelle d'une List
|
||||||
|
List<dynamic> _deepCopyList(List<dynamic> source) {
|
||||||
|
return source.map((item) {
|
||||||
|
if (item is Map) {
|
||||||
|
return _deepCopyMap(Map<String, dynamic>.from(item));
|
||||||
|
} else if (item is List) {
|
||||||
|
return _deepCopyList(item);
|
||||||
|
} else {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$functionName');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage pour éviter les _JsonMap
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
// Log pour débogage (seulement en mode debug)
|
||||||
|
DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Encoder directement avec jsonEncode standard
|
||||||
|
final bodyJson = jsonEncode({'data': preparedData});
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: bodyJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData is Map<String, dynamic> ? responseData : {};
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Error during request: $functionName', e);
|
||||||
|
throw ApiException(
|
||||||
|
message: 'Error calling $functionName: $e',
|
||||||
|
statusCode: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params}) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint').replace(queryParameters: params);
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
final response = await http.get(url, headers: headers);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T?;
|
||||||
|
} else if (response.statusCode == 404) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': preparedData}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': preparedData}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data}) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage si data existe
|
||||||
|
final preparedData = data != null ? _prepareForJson(data) : null;
|
||||||
|
|
||||||
|
final response = await http.delete(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: preparedData != null ? jsonEncode({'data': preparedData}) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appelle une Cloud Function avec pagination
|
||||||
|
Future<Map<String, dynamic>> callPaginated(
|
||||||
|
String functionName,
|
||||||
|
Map<String, dynamic> params,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final url = Uri.parse('$_baseUrl/$functionName');
|
||||||
|
|
||||||
|
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': params}),
|
||||||
|
);
|
||||||
|
|
||||||
|
DebugLog.info('[API] Response status: ${response.statusCode}');
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
DebugLog.error('[API] Error response: ${response.body}');
|
||||||
|
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Exception in callPaginated: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide avec autocomplétion
|
||||||
|
Future<List<Map<String, dynamic>>> quickSearch(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
bool includeEquipments = true,
|
||||||
|
bool includeContainers = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = {
|
||||||
|
'query': query,
|
||||||
|
'limit': limit,
|
||||||
|
'includeEquipments': includeEquipments.toString(),
|
||||||
|
'includeContainers': includeContainers.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await callPaginated('quickSearch', params);
|
||||||
|
final results = response['results'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return results.cast<Map<String, dynamic>>();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Error in quickSearch: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception personnalisée pour les erreurs API
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int statusCode;
|
||||||
|
|
||||||
|
ApiException({
|
||||||
|
required this.message,
|
||||||
|
required this.statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode): $message';
|
||||||
|
|
||||||
|
bool get isForbidden => statusCode == 403;
|
||||||
|
bool get isUnauthorized => statusCode == 401;
|
||||||
|
bool get isNotFound => statusCode == 404;
|
||||||
|
bool get isConflict => statusCode == 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance singleton du service API
|
||||||
|
final ApiService apiService = FirebaseFunctionsApiService();
|
||||||
|
|
||||||
144
em2rp/lib/services/audio_feedback_service.dart
Normal file
144
em2rp/lib/services/audio_feedback_service.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service pour émettre des feedbacks sonores lors des interactions (Web)
|
||||||
|
class AudioFeedbackService {
|
||||||
|
static bool _isInitialized = false;
|
||||||
|
static bool _audioUnlocked = false;
|
||||||
|
|
||||||
|
/// Initialiser le service
|
||||||
|
static Future<void> _initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Initializing audio service for Web...');
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error initializing audio', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Débloquer l'audio (à appeler lors de la première interaction utilisateur)
|
||||||
|
static Future<void> unlockAudio() async {
|
||||||
|
if (_audioUnlocked) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio already unlocked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!_isInitialized) await _initialize();
|
||||||
|
|
||||||
|
DebugLog.info('[AudioFeedbackService] Attempting to unlock audio...');
|
||||||
|
|
||||||
|
// Créer un audio temporaire et le jouer avec volume 0
|
||||||
|
final tempAudio = web.HTMLAudioElement();
|
||||||
|
tempAudio.src = 'assets/assets/sounds/ok.mp3';
|
||||||
|
tempAudio.volume = 0.01; // Volume très faible mais pas 0
|
||||||
|
tempAudio.preload = 'auto';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tempAudio.play().toDart;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
tempAudio.pause();
|
||||||
|
_audioUnlocked = true;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Audio unlocked successfully');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.warning('[AudioFeedbackService] ⚠ Could not unlock audio: $e');
|
||||||
|
DebugLog.warning('[AudioFeedbackService] User interaction may be required');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error unlocking audio', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Créer et jouer un son
|
||||||
|
static Future<void> _playSound(String assetPath, double volume) async {
|
||||||
|
try {
|
||||||
|
if (!_isInitialized) await _initialize();
|
||||||
|
|
||||||
|
DebugLog.info('[AudioFeedbackService] Attempting to play: $assetPath (volume: $volume)');
|
||||||
|
|
||||||
|
// Créer un nouvel élément audio à chaque fois
|
||||||
|
final audio = web.HTMLAudioElement();
|
||||||
|
audio.src = assetPath;
|
||||||
|
audio.volume = volume;
|
||||||
|
audio.preload = 'auto';
|
||||||
|
|
||||||
|
// Ajouter des événements pour debug
|
||||||
|
audio.onloadeddata = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio data loaded: $assetPath');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onerror = ((web.Event event) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Audio error for $assetPath: ${audio.error}');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onplay = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio started playing');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
audio.onended = ((web.Event event) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Audio finished playing');
|
||||||
|
}.toJS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Essayer de jouer
|
||||||
|
await audio.play().toDart;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Sound played successfully');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Play failed: $e');
|
||||||
|
|
||||||
|
// Si c'est un problème d'autoplay, essayer de débloquer
|
||||||
|
if (!_audioUnlocked) {
|
||||||
|
DebugLog.info('[AudioFeedbackService] Trying to unlock audio on error...');
|
||||||
|
_audioUnlocked = false; // Forcer le déblocage
|
||||||
|
await unlockAudio();
|
||||||
|
|
||||||
|
// Réessayer une fois après déblocage
|
||||||
|
try {
|
||||||
|
final retryAudio = web.HTMLAudioElement();
|
||||||
|
retryAudio.src = assetPath;
|
||||||
|
retryAudio.volume = volume;
|
||||||
|
await retryAudio.play().toDart;
|
||||||
|
DebugLog.info('[AudioFeedbackService] ✓ Sound played on retry');
|
||||||
|
} catch (retryError) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] ✗ Retry also failed: $retryError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error in _playSound', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un son de succès
|
||||||
|
static Future<void> playSuccessBeep() async {
|
||||||
|
await _playSound('assets/assets/sounds/ok.mp3', 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un son d'erreur
|
||||||
|
static Future<void> playErrorBeep() async {
|
||||||
|
await _playSound('assets/assets/sounds/error.mp3', 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jouer un feedback complet (son uniquement, sans vibration)
|
||||||
|
static Future<void> playFullFeedback({bool isSuccess = true}) async {
|
||||||
|
if (isSuccess) {
|
||||||
|
await playSuccessBeep();
|
||||||
|
} else {
|
||||||
|
await playErrorBeep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
_isInitialized = false;
|
||||||
|
_audioUnlocked = false;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AudioFeedbackService] Error disposing', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
52
em2rp/lib/services/container_equipment_service.dart
Normal file
52
em2rp/lib/services/container_equipment_service.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Service pour gérer la relation entre containers et équipements
|
||||||
|
/// Utilise le principe : seul le container stocke la référence aux équipements
|
||||||
|
class ContainerEquipmentService {
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
|
/// Récupère tous les containers contenant un équipement spécifique
|
||||||
|
/// Utilise une Cloud Function avec authentification et permissions
|
||||||
|
Future<List<ContainerModel>> getContainersByEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containersData = await _dataService.getContainersByEquipment(equipmentId);
|
||||||
|
|
||||||
|
return containersData.map((data) {
|
||||||
|
// L'ID est dans le champ 'id' retourné par la fonction
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return ContainerModel.fromMap(data, id);
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error getting containers for equipment $equipmentId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est dans au moins un container
|
||||||
|
Future<bool> isEquipmentInAnyContainer(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containers = await getContainersByEquipment(equipmentId);
|
||||||
|
return containers.isNotEmpty;
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error checking if equipment is in container: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le nombre de containers contenant un équipement
|
||||||
|
Future<int> getContainerCountForEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containers = await getContainersByEquipment(equipmentId);
|
||||||
|
return containers.length;
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error getting container count: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance globale singleton
|
||||||
|
final containerEquipmentService = ContainerEquipmentService();
|
||||||
|
|
||||||
@@ -1,61 +1,44 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
|
||||||
class ContainerService {
|
class ContainerService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
// Collection references
|
// ============================================================================
|
||||||
CollectionReference get _containersCollection => _firestore.collection('containers');
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
// ============================================================================
|
||||||
|
|
||||||
// CRUD Operations
|
/// Créer un nouveau container (via Cloud Function)
|
||||||
|
|
||||||
/// Créer un nouveau container
|
|
||||||
Future<void> createContainer(ContainerModel container) async {
|
Future<void> createContainer(ContainerModel container) async {
|
||||||
try {
|
try {
|
||||||
await _containersCollection.doc(container.id).set(container.toMap());
|
await _apiService.call('createContainer', container.toMap()..['id'] = container.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating container: $e');
|
print('Error creating container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un container
|
/// Mettre à jour un container (via Cloud Function)
|
||||||
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
await _apiService.call('updateContainer', {
|
||||||
await _containersCollection.doc(id).update(data);
|
'containerId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating container: $e');
|
print('Error updating container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un container
|
/// Supprimer un container (via Cloud Function)
|
||||||
Future<void> deleteContainer(String id) async {
|
Future<void> deleteContainer(String id) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container pour obtenir les équipements
|
await _apiService.call('deleteContainer', {'containerId': id});
|
||||||
final container = await getContainerById(id);
|
// Note: La Cloud Function gère maintenant la mise à jour des équipements
|
||||||
if (container != null && container.equipmentIds.isNotEmpty) {
|
|
||||||
// Retirer le container des parentBoxIds de chaque équipement
|
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList();
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParents,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _containersCollection.doc(id).delete();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting container: $e');
|
print('Error deleting container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -65,11 +48,10 @@ class ContainerService {
|
|||||||
/// Récupérer un container par ID
|
/// Récupérer un container par ID
|
||||||
Future<ContainerModel?> getContainerById(String id) async {
|
Future<ContainerModel?> getContainerById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _containersCollection.doc(id).get();
|
final containersData = await _dataService.getContainersByIds([id]);
|
||||||
if (doc.exists) {
|
if (containersData.isEmpty) return null;
|
||||||
return ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
return ContainerModel.fromMap(containersData.first, id);
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting container: $e');
|
print('Error getting container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -77,40 +59,40 @@ class ContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer tous les containers
|
/// Récupérer tous les containers
|
||||||
Stream<List<ContainerModel>> getContainers({
|
Future<List<ContainerModel>> getContainers({
|
||||||
ContainerType? type,
|
ContainerType? type,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
}) {
|
}) async {
|
||||||
try {
|
try {
|
||||||
Query query = _containersCollection;
|
final containersData = await _dataService.getContainers();
|
||||||
|
|
||||||
// Filtre par type
|
var containerList = containersData
|
||||||
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filtres côté client
|
||||||
if (type != null) {
|
if (type != null) {
|
||||||
query = query.where('type', isEqualTo: containerTypeToString(type));
|
containerList = containerList
|
||||||
}
|
.where((c) => c.type == type)
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
if (status != null) {
|
|
||||||
query = query.where('status', isEqualTo: equipmentStatusToString(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.snapshots().map((snapshot) {
|
|
||||||
List<ContainerModel> containerList = snapshot.docs
|
|
||||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
|
||||||
.toList();
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Filtre par recherche texte (côté client)
|
if (status != null) {
|
||||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
containerList = containerList
|
||||||
final lowerSearch = searchQuery.toLowerCase();
|
.where((c) => c.status == status)
|
||||||
containerList = containerList.where((container) {
|
.toList();
|
||||||
return container.name.toLowerCase().contains(lowerSearch) ||
|
}
|
||||||
container.id.toLowerCase().contains(lowerSearch);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerList;
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
});
|
final lowerSearch = searchQuery.toLowerCase();
|
||||||
|
containerList = containerList.where((container) {
|
||||||
|
return container.name.toLowerCase().contains(lowerSearch) ||
|
||||||
|
container.id.toLowerCase().contains(lowerSearch);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting containers: $e');
|
print('Error getting containers: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -124,67 +106,16 @@ class ContainerService {
|
|||||||
String? userId,
|
String? userId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container
|
final response = await _apiService.call('addEquipmentToContainer', {
|
||||||
final container = await getContainerById(containerId);
|
'containerId': containerId,
|
||||||
if (container == null) {
|
'equipmentId': equipmentId,
|
||||||
return {'success': false, 'message': 'Container non trouvé'};
|
if (userId != null) 'userId': userId,
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si l'équipement n'est pas déjà dans ce container
|
|
||||||
if (container.equipmentIds.contains(equipmentId)) {
|
|
||||||
return {'success': false, 'message': 'Cet équipement est déjà dans ce container'};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (!equipmentDoc.exists) {
|
|
||||||
return {'success': false, 'message': 'Équipement non trouvé'};
|
|
||||||
}
|
|
||||||
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Avertir si l'équipement est déjà dans d'autres containers
|
|
||||||
List<String> otherContainers = [];
|
|
||||||
if (equipment.parentBoxIds.isNotEmpty) {
|
|
||||||
for (final boxId in equipment.parentBoxIds) {
|
|
||||||
final box = await getContainerById(boxId);
|
|
||||||
if (box != null) {
|
|
||||||
otherContainers.add(box.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour le container
|
|
||||||
final updatedEquipmentIds = [...container.equipmentIds, equipmentId];
|
|
||||||
await updateContainer(containerId, {
|
|
||||||
'equipmentIds': updatedEquipmentIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour l'équipement
|
|
||||||
final updatedParentBoxIds = [...equipment.parentBoxIds, containerId];
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParentBoxIds,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajouter une entrée dans l'historique
|
|
||||||
await _addHistoryEntry(
|
|
||||||
containerId: containerId,
|
|
||||||
action: 'equipment_added',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
newValue: equipmentId,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': response['success'] ?? false,
|
||||||
'message': 'Équipement ajouté avec succès',
|
'message': response['message'] ?? '',
|
||||||
'warnings': otherContainers.isNotEmpty
|
'warnings': response['warnings'],
|
||||||
? 'Attention : cet équipement est également dans les boites suivants : ${otherContainers.join(", ")}'
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding equipment to container: $e');
|
print('Error adding equipment to container: $e');
|
||||||
@@ -199,38 +130,11 @@ class ContainerService {
|
|||||||
String? userId,
|
String? userId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container
|
await _apiService.call('removeEquipmentFromContainer', {
|
||||||
final container = await getContainerById(containerId);
|
'containerId': containerId,
|
||||||
if (container == null) throw Exception('Container non trouvé');
|
'equipmentId': equipmentId,
|
||||||
|
if (userId != null) 'userId': userId,
|
||||||
// Mettre à jour le container
|
|
||||||
final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList();
|
|
||||||
await updateContainer(containerId, {
|
|
||||||
'equipmentIds': updatedEquipmentIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour l'équipement
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList();
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParentBoxIds,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter une entrée dans l'historique
|
|
||||||
await _addHistoryEntry(
|
|
||||||
containerId: containerId,
|
|
||||||
action: 'equipment_removed',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
previousValue: equipmentId,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error removing equipment from container: $e');
|
print('Error removing equipment from container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -260,14 +164,13 @@ class ContainerService {
|
|||||||
|
|
||||||
// Vérifier la disponibilité de chaque équipement dans le container
|
// Vérifier la disponibilité de chaque équipement dans le container
|
||||||
List<String> unavailableEquipment = [];
|
List<String> unavailableEquipment = [];
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (container.equipmentIds.isNotEmpty) {
|
||||||
|
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
||||||
|
|
||||||
|
for (var data in equipmentsData) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
final equipment = EquipmentModel.fromMap(data, id);
|
||||||
if (equipment.status != EquipmentStatus.available) {
|
if (equipment.status != EquipmentStatus.available) {
|
||||||
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
||||||
}
|
}
|
||||||
@@ -295,15 +198,16 @@ class ContainerService {
|
|||||||
final container = await getContainerById(containerId);
|
final container = await getContainerById(containerId);
|
||||||
if (container == null) return [];
|
if (container == null) return [];
|
||||||
|
|
||||||
List<EquipmentModel> equipment = [];
|
if (container.equipmentIds.isEmpty) return [];
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final doc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
equipment.add(EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipment;
|
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
||||||
|
|
||||||
|
return equipmentsData
|
||||||
|
.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting container equipment: $e');
|
print('Error getting container equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -313,12 +217,10 @@ class ContainerService {
|
|||||||
/// Trouver tous les containers contenant un équipement spécifique
|
/// Trouver tous les containers contenant un équipement spécifique
|
||||||
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await _containersCollection
|
final containersData = await _dataService.getContainersByEquipment(equipmentId);
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
return snapshot.docs
|
return containersData
|
||||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error finding containers with equipment: $e');
|
print('Error finding containers with equipment: $e');
|
||||||
@@ -367,8 +269,8 @@ class ContainerService {
|
|||||||
/// Vérifier si un ID de container existe déjà
|
/// Vérifier si un ID de container existe déjà
|
||||||
Future<bool> checkContainerIdExists(String id) async {
|
Future<bool> checkContainerIdExists(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _containersCollection.doc(id).get();
|
final container = await getContainerById(id);
|
||||||
return doc.exists;
|
return container != null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking container ID: $e');
|
print('Error checking container ID: $e');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
710
em2rp/lib/services/data_service.dart
Normal file
710
em2rp/lib/services/data_service.dart
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service générique pour les opérations de lecture de données via Cloud Functions
|
||||||
|
class DataService {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
DataService(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère toutes les options
|
||||||
|
Future<List<Map<String, dynamic>>> getOptions() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getOptions', {});
|
||||||
|
final options = result['options'] as List<dynamic>?;
|
||||||
|
if (options == null) return [];
|
||||||
|
return options.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des options: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les types d'événements
|
||||||
|
Future<List<Map<String, dynamic>>> getEventTypes() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getEventTypes', {});
|
||||||
|
final eventTypes = result['eventTypes'] as List<dynamic>?;
|
||||||
|
if (eventTypes == null) return [];
|
||||||
|
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les rôles
|
||||||
|
Future<List<Map<String, dynamic>>> getRoles() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getRoles', {});
|
||||||
|
final roles = result['roles'] as List<dynamic>?;
|
||||||
|
if (roles == null) return [];
|
||||||
|
return roles.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des rôles: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour les équipements d'un événement
|
||||||
|
Future<void> updateEventEquipment({
|
||||||
|
required String eventId,
|
||||||
|
List<Map<String, dynamic>>? assignedEquipment,
|
||||||
|
String? preparationStatus,
|
||||||
|
String? loadingStatus,
|
||||||
|
String? unloadingStatus,
|
||||||
|
String? returnStatus,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventId': eventId};
|
||||||
|
|
||||||
|
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
|
||||||
|
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
|
||||||
|
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||||
|
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||||
|
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventEquipment', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour uniquement le statut d'un équipement
|
||||||
|
Future<void> updateEquipmentStatusOnly({
|
||||||
|
required String equipmentId,
|
||||||
|
String? status,
|
||||||
|
int? availableQuantity,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||||
|
|
||||||
|
if (status != null) data['status'] = status;
|
||||||
|
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
|
||||||
|
|
||||||
|
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un événement
|
||||||
|
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
// Correction : fusionner eventId et les champs de data à la racine
|
||||||
|
final requestData = {'eventId': eventId, ...data};
|
||||||
|
await _apiService.call('updateEvent', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un événement
|
||||||
|
Future<void> deleteEvent(String eventId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un équipement
|
||||||
|
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
// S'assurer que l'ID est dans les données
|
||||||
|
final equipmentData = Map<String, dynamic>.from(data);
|
||||||
|
equipmentData['id'] = equipmentId;
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', equipmentData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un équipement
|
||||||
|
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements utilisant un type d'événement donné
|
||||||
|
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||||
|
final events = result['events'] as List<dynamic>?;
|
||||||
|
if (events == null) return [];
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un type d'événement
|
||||||
|
Future<String> createEventType({
|
||||||
|
required String name,
|
||||||
|
required double defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createEventType', {
|
||||||
|
'name': name,
|
||||||
|
'defaultPrice': defaultPrice,
|
||||||
|
});
|
||||||
|
return result['id'] as String;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un type d'événement
|
||||||
|
Future<void> updateEventType({
|
||||||
|
required String eventTypeId,
|
||||||
|
String? name,
|
||||||
|
double? defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventTypeId': eventTypeId};
|
||||||
|
if (name != null) data['name'] = name;
|
||||||
|
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventType', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un type d'événement
|
||||||
|
Future<void> deleteEventType(String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une option
|
||||||
|
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {
|
||||||
|
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
|
||||||
|
'code': code,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
final result = await _apiService.call('createOption', requestData);
|
||||||
|
return result['id'] as String? ?? code;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour une option
|
||||||
|
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'optionId': optionId, ...data};
|
||||||
|
await _apiService.call('updateOption', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une option
|
||||||
|
Future<void> deleteOption(String optionId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteOption', {'optionId': optionId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LECTURE DES DONNÉES (avec permissions côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère tous les événements (filtrés selon permissions)
|
||||||
|
/// Retourne { events: List<Map>, users: Map<String, Map> }
|
||||||
|
Future<Map<String, dynamic>> getEvents({String? userId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (userId != null) data['userId'] = userId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getEvents', data);
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
|
Future<Map<String, dynamic>> getEventsByMonth({
|
||||||
|
required String userId,
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getEventsByMonth for $year-$month');
|
||||||
|
final result = await _apiService.call('getEventsByMonth', {
|
||||||
|
'userId': userId,
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting events by month: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||||
|
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Getting event with details: $eventId');
|
||||||
|
final result = await _apiService.call('getEventWithDetails', {
|
||||||
|
'eventId': eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final event = result['event'] as Map<String, dynamic>?;
|
||||||
|
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||||
|
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
if (event == null) {
|
||||||
|
throw Exception('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'event': event,
|
||||||
|
'equipments': equipments,
|
||||||
|
'containers': containers,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting event with details: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getEquipments API...');
|
||||||
|
final result = await _apiService.call('getEquipments', {});
|
||||||
|
print('[DataService] API call successful, parsing result...');
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[DataService] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${equipments.length} equipments');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting equipments: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs équipements par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||||
|
try {
|
||||||
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||||
|
final result = await _apiService.call('getEquipmentsByIds', {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
});
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[DataService] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${equipments.length} equipments by IDs');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting equipments by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les conteneurs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainers', {});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des conteneurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs containers par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
|
||||||
|
try {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||||
|
final result = await _apiService.call('getContainersByIds', {
|
||||||
|
'containerIds': containerIds,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) {
|
||||||
|
print('[DataService] No containers in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${containers.length} containers by IDs');
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting containers by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EQUIPMENTS & CONTAINERS - Pagination
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère les équipements avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getEquipmentsPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? category,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getEquipmentsPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipments': (result['equipments'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ?? [],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
||||||
|
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getContainersPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? type,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String? category,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (type != null) params['type'] = type;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getContainersPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'containers': (result['containers'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ?? [],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
||||||
|
throw Exception('Erreur lors de la récupération paginée des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide (autocomplétion)
|
||||||
|
Future<List<Map<String, dynamic>>> quickSearch(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
bool includeEquipments = true,
|
||||||
|
bool includeContainers = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
|
||||||
|
query,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: includeEquipments,
|
||||||
|
includeContainers: includeContainers,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in quickSearch', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER - Current User
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||||
|
Future<Map<String, dynamic>> getCurrentUser() async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getCurrentUser API...');
|
||||||
|
final result = await _apiService.call('getCurrentUser', {});
|
||||||
|
print('[DataService] Current user loaded successfully');
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting current user: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère toutes les alertes
|
||||||
|
Future<List<Map<String, dynamic>>> getAlerts() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getAlerts', {});
|
||||||
|
final alerts = result['alerts'] as List<dynamic>?;
|
||||||
|
if (alerts == null) return [];
|
||||||
|
return alerts.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des alertes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque une alerte comme lue
|
||||||
|
Future<void> markAlertAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une alerte
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EQUIPMENT AVAILABILITY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('checkEquipmentAvailability', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
|
||||||
|
/// Optimisé : une seule requête au lieu d'une par équipement
|
||||||
|
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
int installationTime = 0,
|
||||||
|
int disassemblyTime = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getConflictingEquipmentIds', {
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
'installationTime': installationTime,
|
||||||
|
'disassemblyTime': disassemblyTime,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAINTENANCES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère toutes les maintenances
|
||||||
|
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getMaintenances', data);
|
||||||
|
final maintenances = result['maintenances'] as List<dynamic>?;
|
||||||
|
if (maintenances == null) return [];
|
||||||
|
return maintenances.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des maintenances: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une maintenance
|
||||||
|
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers contenant un équipement
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainersByEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère tous les utilisateurs (selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUsers', {});
|
||||||
|
final users = result['users'] as List<dynamic>?;
|
||||||
|
if (users == null) return [];
|
||||||
|
return users.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un utilisateur spécifique
|
||||||
|
Future<Map<String, dynamic>> getUser(String userId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUser', {'userId': userId});
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un utilisateur (Auth + Firestore)
|
||||||
|
Future<void> deleteUser(String userId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteUser', {'userId': userId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un utilisateur
|
||||||
|
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateUser', {
|
||||||
|
'userId': userId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un utilisateur avec invitation par email
|
||||||
|
Future<Map<String, dynamic>> createUserWithInvite({
|
||||||
|
required String email,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
required String roleId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createUserWithInvite', {
|
||||||
|
'email': email,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'phoneNumber': phoneNumber ?? '',
|
||||||
|
'roleId': roleId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
em2rp/lib/services/email_service.dart
Normal file
149
em2rp/lib/services/email_service.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
|
/// Service d'envoi d'emails via Cloud Functions
|
||||||
|
class EmailService {
|
||||||
|
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte à un utilisateur
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userId] : ID de l'utilisateur destinataire
|
||||||
|
/// [templateType] : Type de template à utiliser (par défaut: 'alert-individual')
|
||||||
|
Future<bool> sendAlertEmail({
|
||||||
|
required AlertModel alert,
|
||||||
|
required String userId,
|
||||||
|
String templateType = 'alert-individual',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Vérifier que l'utilisateur est authentifié
|
||||||
|
final currentUser = FirebaseAuth.instance.currentUser;
|
||||||
|
if (currentUser == null) {
|
||||||
|
DebugLog.error('[EmailService] Utilisateur non authentifié');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId');
|
||||||
|
|
||||||
|
final result = await _functions.httpsCallable('sendAlertEmail').call({
|
||||||
|
'alertId': alert.id,
|
||||||
|
'userId': userId,
|
||||||
|
'templateType': templateType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = result.data as Map<String, dynamic>;
|
||||||
|
final success = data['success'] as bool? ?? false;
|
||||||
|
final skipped = data['skipped'] as bool? ?? false;
|
||||||
|
|
||||||
|
if (skipped) {
|
||||||
|
final reason = data['reason'] as String? ?? 'unknown';
|
||||||
|
DebugLog.info('[EmailService] Email non envoyé: $reason');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
DebugLog.info('[EmailService] Email envoyé avec succès');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EmailService] Erreur envoi email', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte à plusieurs utilisateurs
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||||
|
Future<Map<String, bool>> sendAlertEmailToMultipleUsers({
|
||||||
|
required AlertModel alert,
|
||||||
|
required List<String> userIds,
|
||||||
|
String templateType = 'alert-individual',
|
||||||
|
}) async {
|
||||||
|
final results = <String, bool>{};
|
||||||
|
|
||||||
|
DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs');
|
||||||
|
|
||||||
|
// Envoyer en parallèle (max 5 à la fois pour éviter surcharge)
|
||||||
|
final batches = <List<String>>[];
|
||||||
|
for (var i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.add(userIds.sublist(
|
||||||
|
i,
|
||||||
|
i + 5 > userIds.length ? userIds.length : i + 5,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final batch in batches) {
|
||||||
|
final futures = batch.map((userId) => sendAlertEmail(
|
||||||
|
alert: alert,
|
||||||
|
userId: userId,
|
||||||
|
templateType: templateType,
|
||||||
|
));
|
||||||
|
|
||||||
|
final batchResults = await Future.wait(futures);
|
||||||
|
|
||||||
|
for (var i = 0; i < batch.length; i++) {
|
||||||
|
results[batch[i]] = batchResults[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final successCount = results.values.where((v) => v).length;
|
||||||
|
DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés');
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détermine si une alerte doit être envoyée immédiatement ou en digest
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à vérifier
|
||||||
|
/// Returns: true si immédiat, false si digest
|
||||||
|
bool shouldSendImmediate(AlertModel alert) {
|
||||||
|
// Les alertes critiques sont envoyées immédiatement
|
||||||
|
if (alert.severity == AlertSeverity.critical) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'alertes toujours immédiates
|
||||||
|
const immediateTypes = [
|
||||||
|
AlertType.lost, // Équipement perdu
|
||||||
|
AlertType.eventCancelled, // Événement annulé
|
||||||
|
];
|
||||||
|
|
||||||
|
return immediateTypes.contains(alert.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte en tenant compte des préférences
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||||
|
Future<void> sendAlertWithPreferences({
|
||||||
|
required AlertModel alert,
|
||||||
|
required List<String> userIds,
|
||||||
|
}) async {
|
||||||
|
if (userIds.isEmpty) {
|
||||||
|
DebugLog.warning('[EmailService] Aucun utilisateur à notifier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final immediate = shouldSendImmediate(alert);
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
DebugLog.info('[EmailService] Envoi immédiat (alerte critique)');
|
||||||
|
await sendAlertEmailToMultipleUsers(
|
||||||
|
alert: alert,
|
||||||
|
userIds: userIds,
|
||||||
|
templateType: 'alert-individual',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
DebugLog.info('[EmailService] Ajout au digest (alerte non critique)');
|
||||||
|
// Les alertes non critiques seront envoyées dans le digest quotidien
|
||||||
|
// La Cloud Function sendDailyDigest s'en occupera
|
||||||
|
// Rien à faire ici, les alertes sont déjà dans Firestore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,57 +1,101 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/maintenance_service.dart';
|
||||||
|
|
||||||
class EquipmentService {
|
class EquipmentService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
// Collection references
|
// ============================================================================
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
// Helper privée - Charge TOUS les équipements avec pagination
|
||||||
CollectionReference get _alertsCollection => _firestore.collection('alerts');
|
// ============================================================================
|
||||||
CollectionReference get _eventsCollection => _firestore.collection('events');
|
|
||||||
|
|
||||||
// CRUD Operations
|
/// Charge tous les équipements en utilisant la pagination
|
||||||
|
Future<List<Map<String, dynamic>>> _getAllEquipmentsPaginated() async {
|
||||||
|
final allEquipments = <Map<String, dynamic>>[];
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
|
||||||
/// Créer un nouvel équipement
|
while (hasMore) {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 100,
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipments = result['equipments'] as List<dynamic>;
|
||||||
|
allEquipments.addAll(equipments.cast<Map<String, dynamic>>());
|
||||||
|
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEquipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Créer un nouvel équipement (via Cloud Function)
|
||||||
Future<void> createEquipment(EquipmentModel equipment) async {
|
Future<void> createEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _equipmentCollection.doc(equipment.id).set(equipment.toMap());
|
if (equipment.id.isEmpty) {
|
||||||
|
throw Exception('L\'ID de l\'équipement est requis pour la création');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = equipment.toMap();
|
||||||
|
data['id'] = equipment.id; // S'assurer que l'ID est inclus
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating equipment: $e');
|
print('Error creating equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un équipement
|
/// Mettre à jour un équipement (via Cloud Function)
|
||||||
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
if (data.isEmpty) {
|
||||||
await _equipmentCollection.doc(id).update(data);
|
throw Exception('Aucune donnée à mettre à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiService.call('updateEquipment', {
|
||||||
|
'equipmentId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating equipment: $e');
|
print('Error updating equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un équipement
|
/// Supprimer un équipement (via Cloud Function)
|
||||||
Future<void> deleteEquipment(String id) async {
|
Future<void> deleteEquipment(String id) async {
|
||||||
try {
|
try {
|
||||||
await _equipmentCollection.doc(id).delete();
|
await _apiService.call('deleteEquipment', {'equipmentId': id});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting equipment: $e');
|
print('Error deleting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations - Utilise Firestore streams (temps réel)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Récupérer un équipement par ID
|
/// Récupérer un équipement par ID
|
||||||
Future<EquipmentModel?> getEquipmentById(String id) async {
|
Future<EquipmentModel?> getEquipmentById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _equipmentCollection.doc(id).get();
|
final equipmentsData = await _dataService.getEquipmentsByIds([id]);
|
||||||
if (doc.exists) {
|
if (equipmentsData.isEmpty) return null;
|
||||||
return EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
return EquipmentModel.fromMap(equipmentsData.first, id);
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting equipment: $e');
|
print('Error getting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -59,81 +103,77 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les équipements avec filtres
|
/// Récupérer les équipements avec filtres
|
||||||
Stream<List<EquipmentModel>> getEquipment({
|
Future<List<EquipmentModel>> getEquipment({
|
||||||
EquipmentCategory? category,
|
EquipmentCategory? category,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
String? model,
|
String? model,
|
||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
}) {
|
}) async {
|
||||||
try {
|
try {
|
||||||
Query query = _equipmentCollection;
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
|
|
||||||
// Filtre par catégorie
|
var equipmentList = equipmentsData
|
||||||
|
.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filtres côté client
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
query = query.where('category', isEqualTo: equipmentCategoryToString(category));
|
equipmentList = equipmentList
|
||||||
}
|
.where((e) => e.category == category)
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
if (status != null) {
|
|
||||||
query = query.where('status', isEqualTo: equipmentStatusToString(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par modèle
|
|
||||||
if (model != null && model.isNotEmpty) {
|
|
||||||
query = query.where('model', isEqualTo: model);
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.snapshots().map((snapshot) {
|
|
||||||
List<EquipmentModel> equipmentList = snapshot.docs
|
|
||||||
.map((doc) => EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
|
||||||
.toList();
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Filtre par recherche texte (côté client car Firestore ne supporte pas les recherches texte complexes)
|
if (status != null) {
|
||||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
equipmentList = equipmentList
|
||||||
final lowerSearch = searchQuery.toLowerCase();
|
.where((e) => e.status == status)
|
||||||
equipmentList = equipmentList.where((equipment) {
|
.toList();
|
||||||
return equipment.name.toLowerCase().contains(lowerSearch) ||
|
}
|
||||||
(equipment.model?.toLowerCase().contains(lowerSearch) ?? false) ||
|
|
||||||
equipment.id.toLowerCase().contains(lowerSearch);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipmentList;
|
if (model != null && model.isNotEmpty) {
|
||||||
});
|
equipmentList = equipmentList
|
||||||
|
.where((e) => e.model == model)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
final lowerSearch = searchQuery.toLowerCase();
|
||||||
|
equipmentList = equipmentList.where((equipment) {
|
||||||
|
return equipment.name.toLowerCase().contains(lowerSearch) ||
|
||||||
|
(equipment.model?.toLowerCase().contains(lowerSearch) ?? false) ||
|
||||||
|
equipment.id.toLowerCase().contains(lowerSearch);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return equipmentList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming equipment: $e');
|
print('Error getting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability & Stock Management - Logique métier côté client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Vérifier la disponibilité d'un équipement pour une période donnée
|
/// Vérifier la disponibilité d'un équipement pour une période donnée
|
||||||
Future<List<String>> checkAvailability(
|
Future<List<Map<String, dynamic>>> checkAvailability(
|
||||||
String equipmentId,
|
String equipmentId,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final conflicts = <String>[];
|
final response = await _apiService.call('checkEquipmentAvailability', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
// Récupérer tous les événements qui chevauchent la période
|
final conflicts = (response['conflicts'] as List?)
|
||||||
final eventsQuery = await _eventsCollection
|
?.map((c) => c as Map<String, dynamic>)
|
||||||
.where('StartDateTime', isLessThanOrEqualTo: Timestamp.fromDate(endDate))
|
.toList() ?? [];
|
||||||
.where('EndDateTime', isGreaterThanOrEqualTo: Timestamp.fromDate(startDate))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var eventDoc in eventsQuery.docs) {
|
|
||||||
final eventData = eventDoc.data() as Map<String, dynamic>;
|
|
||||||
final assignedEquipmentRaw = eventData['assignedEquipment'] ?? [];
|
|
||||||
|
|
||||||
if (assignedEquipmentRaw is List) {
|
|
||||||
for (var eq in assignedEquipmentRaw) {
|
|
||||||
if (eq is Map && eq['equipmentId'] == equipmentId) {
|
|
||||||
conflicts.add(eventDoc.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
return conflicts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -149,26 +189,19 @@ class EquipmentService {
|
|||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer tous les équipements du même modèle
|
final response = await _apiService.call('findAlternativeEquipment', {
|
||||||
final equipmentQuery = await _equipmentCollection
|
'model': model,
|
||||||
.where('model', isEqualTo: model)
|
'startDate': startDate.toIso8601String(),
|
||||||
.get();
|
'endDate': endDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
final alternatives = <EquipmentModel>[];
|
final alternatives = (response['alternatives'] as List?)
|
||||||
|
?.map((a) {
|
||||||
for (var doc in equipmentQuery.docs) {
|
final map = a as Map<String, dynamic>;
|
||||||
final equipment = EquipmentModel.fromMap(
|
final id = map['id'] as String;
|
||||||
doc.data() as Map<String, dynamic>,
|
return EquipmentModel.fromMap(map, id);
|
||||||
doc.id,
|
})
|
||||||
);
|
.toList() ?? [];
|
||||||
|
|
||||||
// Vérifier la disponibilité
|
|
||||||
final conflicts = await checkAvailability(equipment.id, startDate, endDate);
|
|
||||||
|
|
||||||
if (conflicts.isEmpty && equipment.status == EquipmentStatus.available) {
|
|
||||||
alternatives.add(equipment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alternatives;
|
return alternatives;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -206,56 +239,22 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifier les stocks critiques et créer des alertes
|
|
||||||
Future<void> checkCriticalStock() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection
|
|
||||||
.where('category', whereIn: [
|
|
||||||
equipmentCategoryToString(EquipmentCategory.consumable),
|
|
||||||
equipmentCategoryToString(EquipmentCategory.cable),
|
|
||||||
])
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.isCriticalStock) {
|
|
||||||
await _createLowStockAlert(equipment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking critical stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Créer une alerte de stock faible
|
/// Créer une alerte de stock faible
|
||||||
Future<void> _createLowStockAlert(EquipmentModel equipment) async {
|
Future<void> _createLowStockAlert(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
// Vérifier si une alerte existe déjà pour cet équipement
|
// Note: Cette fonction pourrait utiliser une Cloud Function dédiée dans le futur
|
||||||
final existingAlerts = await _alertsCollection
|
// Pour l'instant, on utilise l'API directement pour éviter de créer trop de fonctions
|
||||||
.where('equipmentId', isEqualTo: equipment.id)
|
// Cette méthode est appelée rarement et en arrière-plan
|
||||||
.where('type', isEqualTo: alertTypeToString(AlertType.lowStock))
|
await _apiService.call('createAlert', {
|
||||||
.where('isRead', isEqualTo: false)
|
'type': 'LOW_STOCK',
|
||||||
.get();
|
'title': 'Stock critique',
|
||||||
|
'message': 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
|
||||||
if (existingAlerts.docs.isEmpty) {
|
'severity': 'HIGH',
|
||||||
final alert = AlertModel(
|
'equipmentId': equipment.id,
|
||||||
id: _alertsCollection.doc().id,
|
});
|
||||||
type: AlertType.lowStock,
|
|
||||||
message: 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
|
|
||||||
equipmentId: equipment.id,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _alertsCollection.doc(alert.id).set(alert.toMap());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating low stock alert: $e');
|
print('Error creating low stock alert: $e');
|
||||||
rethrow;
|
// Ne pas rethrow pour ne pas bloquer le processus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,61 +265,18 @@ class EquipmentService {
|
|||||||
return equipmentId;
|
return equipmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer tous les modèles uniques (pour l'indexation/autocomplete)
|
|
||||||
Future<List<String>> getAllModels() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection.get();
|
|
||||||
final models = <String>{};
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
|
||||||
final model = data['model'] as String?;
|
|
||||||
if (model != null && model.isNotEmpty) {
|
|
||||||
models.add(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return models.toList()..sort();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting all models: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer toutes les marques uniques (pour l'indexation/autocomplete)
|
|
||||||
Future<List<String>> getAllBrands() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection.get();
|
|
||||||
final brands = <String>{};
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
|
||||||
final brand = data['brand'] as String?;
|
|
||||||
if (brand != null && brand.isNotEmpty) {
|
|
||||||
brands.add(brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return brands.toList()..sort();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting all brands: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer les modèles filtrés par marque
|
/// Récupérer les modèles filtrés par marque
|
||||||
Future<List<String>> getModelsByBrand(String brand) async {
|
Future<List<String>> getModelsByBrand(String brand) async {
|
||||||
try {
|
try {
|
||||||
final equipmentQuery = await _equipmentCollection
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
.where('brand', isEqualTo: brand)
|
|
||||||
.get();
|
|
||||||
final models = <String>{};
|
final models = <String>{};
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
for (var data in equipmentsData) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
if (data['brand'] == brand) {
|
||||||
final model = data['model'] as String?;
|
final model = data['model'] as String?;
|
||||||
if (model != null && model.isNotEmpty) {
|
if (model != null && model.isNotEmpty) {
|
||||||
models.add(model);
|
models.add(model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,37 +287,51 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupérer les sous-catégories filtrées par catégorie
|
||||||
|
Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async {
|
||||||
|
try {
|
||||||
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
|
final subCategories = <String>{};
|
||||||
|
|
||||||
|
final categoryString = equipmentCategoryToString(category);
|
||||||
|
|
||||||
|
for (var data in equipmentsData) {
|
||||||
|
if (data['category'] == categoryString) {
|
||||||
|
final subCategory = data['subCategory'] as String?;
|
||||||
|
if (subCategory != null && subCategory.isNotEmpty) {
|
||||||
|
subCategories.add(subCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subCategories.toList()..sort();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting subcategories by category: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Vérifier si un ID existe déjà
|
/// Vérifier si un ID existe déjà
|
||||||
Future<bool> isIdUnique(String id) async {
|
Future<bool> isIdUnique(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _equipmentCollection.doc(id).get();
|
final equipment = await getEquipmentById(id);
|
||||||
return !doc.exists;
|
return equipment == null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking ID uniqueness: $e');
|
print('Error checking ID uniqueness: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer toutes les boîtes (équipements qui peuvent contenir d'autres équipements)
|
/// Récupérer toutes les boîtes/containers disponibles
|
||||||
Future<List<EquipmentModel>> getBoxes() async {
|
Future<List<ContainerModel>> getBoxes() async {
|
||||||
try {
|
try {
|
||||||
// Les boîtes sont généralement des équipements de catégorie "structure" ou "other"
|
final containersData = await _dataService.getContainers();
|
||||||
// On pourrait aussi ajouter un champ spécifique "isBox" dans le modèle
|
|
||||||
final equipmentQuery = await _equipmentCollection
|
|
||||||
.where('category', whereIn: [
|
|
||||||
equipmentCategoryToString(EquipmentCategory.structure),
|
|
||||||
equipmentCategoryToString(EquipmentCategory.other),
|
|
||||||
])
|
|
||||||
.get();
|
|
||||||
|
|
||||||
final boxes = <EquipmentModel>[];
|
final boxes = <ContainerModel>[];
|
||||||
for (var doc in equipmentQuery.docs) {
|
for (var data in containersData) {
|
||||||
final equipment = EquipmentModel.fromMap(
|
final id = data['id'] as String;
|
||||||
doc.data() as Map<String, dynamic>,
|
final container = ContainerModel.fromMap(data, id);
|
||||||
doc.id,
|
boxes.add(container);
|
||||||
);
|
|
||||||
// On pourrait ajouter un filtre supplémentaire ici si besoin
|
|
||||||
boxes.add(equipment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return boxes;
|
return boxes;
|
||||||
@@ -376,27 +346,14 @@ class EquipmentService {
|
|||||||
try {
|
try {
|
||||||
if (ids.isEmpty) return [];
|
if (ids.isEmpty) return [];
|
||||||
|
|
||||||
final equipments = <EquipmentModel>[];
|
final equipmentsData = await _dataService.getEquipmentsByIds(ids);
|
||||||
|
|
||||||
// Firestore limite les requêtes whereIn à 10 éléments
|
return equipmentsData
|
||||||
// On doit donc diviser en plusieurs requêtes si nécessaire
|
.map((data) {
|
||||||
for (int i = 0; i < ids.length; i += 10) {
|
final id = data['id'] as String;
|
||||||
final batch = ids.skip(i).take(10).toList();
|
return EquipmentModel.fromMap(data, id);
|
||||||
final query = await _equipmentCollection
|
})
|
||||||
.where(FieldPath.documentId, whereIn: batch)
|
.toList();
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in query.docs) {
|
|
||||||
equipments.add(
|
|
||||||
EquipmentModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipments;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting equipments by IDs: $e');
|
print('Error getting equipments by IDs: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -404,25 +361,13 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les maintenances pour un équipement
|
/// Récupérer les maintenances pour un équipement
|
||||||
|
/// Note: Cette méthode est maintenant déléguée au MaintenanceService
|
||||||
|
/// pour éviter la duplication de code
|
||||||
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final maintenanceQuery = await _firestore
|
// Déléguer au MaintenanceService qui utilise déjà les Cloud Functions
|
||||||
.collection('maintenances')
|
final maintenanceService = MaintenanceService();
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
return await maintenanceService.getMaintenancesByEquipment(equipmentId);
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
final maintenances = <MaintenanceModel>[];
|
|
||||||
for (var doc in maintenanceQuery.docs) {
|
|
||||||
maintenances.add(
|
|
||||||
MaintenanceModel.fromMap(
|
|
||||||
doc.data(),
|
|
||||||
doc.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return maintenances;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenances for equipment: $e');
|
print('Error getting maintenances for equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
86
em2rp/lib/services/equipment_status_calculator.dart
Normal file
86
em2rp/lib/services/equipment_status_calculator.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Service pour calculer dynamiquement le statut réel d'un équipement
|
||||||
|
/// basé sur les événements en cours
|
||||||
|
class EquipmentStatusCalculator {
|
||||||
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
|
/// Cache des statuts pour éviter de multiples requêtes
|
||||||
|
Map<String, EquipmentStatus>? _cachedStatuses;
|
||||||
|
DateTime? _cacheTime;
|
||||||
|
static const _cacheDuration = Duration(minutes: 1);
|
||||||
|
|
||||||
|
/// Instance statique pour permettre l'invalidation depuis n'importe où
|
||||||
|
static final EquipmentStatusCalculator _instance = EquipmentStatusCalculator._internal();
|
||||||
|
|
||||||
|
factory EquipmentStatusCalculator() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentStatusCalculator._internal();
|
||||||
|
|
||||||
|
/// Calcule le statut réel d'un équipement basé sur les événements
|
||||||
|
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
|
||||||
|
print('[StatusCalculator] Calculating status for: ${equipment.id}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final statuses = await calculateMultipleStatuses([equipment]);
|
||||||
|
return statuses[equipment.id] ?? equipment.status;
|
||||||
|
} catch (e) {
|
||||||
|
print('[StatusCalculator] Error calculating status: $e');
|
||||||
|
return equipment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule les statuts pour une liste d'équipements (optimisé)
|
||||||
|
Future<Map<String, EquipmentStatus>> calculateMultipleStatuses(
|
||||||
|
List<EquipmentModel> equipments,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final equipmentIds = equipments.map((e) => e.id).toList();
|
||||||
|
|
||||||
|
final response = await _apiService.call('calculateEquipmentStatuses', {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
final statusesMap = response['statuses'] as Map<String, dynamic>?;
|
||||||
|
if (statusesMap == null) {
|
||||||
|
throw Exception('Invalid response from calculateEquipmentStatuses');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statuses = <String, EquipmentStatus>{};
|
||||||
|
statusesMap.forEach((equipmentId, statusString) {
|
||||||
|
if (statusString != null) {
|
||||||
|
statuses[equipmentId] = equipmentStatusFromString(statusString as String);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mise en cache
|
||||||
|
_cachedStatuses = statuses;
|
||||||
|
_cacheTime = DateTime.now();
|
||||||
|
|
||||||
|
return statuses;
|
||||||
|
} catch (e) {
|
||||||
|
print('[StatusCalculator] Error calculating multiple statuses: $e');
|
||||||
|
// En cas d'erreur, retourner les statuts actuels
|
||||||
|
final fallbackStatuses = <String, EquipmentStatus>{};
|
||||||
|
for (var equipment in equipments) {
|
||||||
|
fallbackStatuses[equipment.id] = equipment.status;
|
||||||
|
}
|
||||||
|
return fallbackStatuses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalide le cache (à appeler après une modification d'événement)
|
||||||
|
void invalidateCache() {
|
||||||
|
_cachedStatuses = null;
|
||||||
|
_cacheTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalide le cache de l'instance globale (méthode statique)
|
||||||
|
static void invalidateGlobalCache() {
|
||||||
|
_instance.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
|
||||||
|
/// Type de conflit
|
||||||
|
enum ConflictType {
|
||||||
|
equipmentUnavailable, // Équipement non quantifiable utilisé
|
||||||
|
insufficientQuantity, // Quantité insuffisante pour consommable/câble
|
||||||
|
containerFullyUsed, // Boîte complète utilisée
|
||||||
|
containerPartiallyUsed, // Certains équipements de la boîte utilisés
|
||||||
|
}
|
||||||
|
|
||||||
/// Informations sur un conflit de disponibilité
|
/// Informations sur un conflit de disponibilité
|
||||||
class AvailabilityConflict {
|
class AvailabilityConflict {
|
||||||
@@ -8,20 +18,62 @@ class AvailabilityConflict {
|
|||||||
final String equipmentName;
|
final String equipmentName;
|
||||||
final EventModel conflictingEvent;
|
final EventModel conflictingEvent;
|
||||||
final int overlapDays;
|
final int overlapDays;
|
||||||
|
final ConflictType type;
|
||||||
|
|
||||||
|
// Pour les quantités (consommables/câbles)
|
||||||
|
final int? totalQuantity;
|
||||||
|
final int? availableQuantity;
|
||||||
|
final int? requestedQuantity;
|
||||||
|
final int? reservedQuantity;
|
||||||
|
|
||||||
|
// Pour les boîtes
|
||||||
|
final String? containerId;
|
||||||
|
final String? containerName;
|
||||||
|
final List<String>? conflictingChildrenIds;
|
||||||
|
|
||||||
AvailabilityConflict({
|
AvailabilityConflict({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
required this.equipmentName,
|
required this.equipmentName,
|
||||||
required this.conflictingEvent,
|
required this.conflictingEvent,
|
||||||
required this.overlapDays,
|
required this.overlapDays,
|
||||||
|
this.type = ConflictType.equipmentUnavailable,
|
||||||
|
this.totalQuantity,
|
||||||
|
this.availableQuantity,
|
||||||
|
this.requestedQuantity,
|
||||||
|
this.reservedQuantity,
|
||||||
|
this.containerId,
|
||||||
|
this.containerName,
|
||||||
|
this.conflictingChildrenIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Message descriptif du conflit
|
||||||
|
String get conflictMessage {
|
||||||
|
switch (type) {
|
||||||
|
case ConflictType.equipmentUnavailable:
|
||||||
|
return 'Équipement déjà utilisé';
|
||||||
|
case ConflictType.insufficientQuantity:
|
||||||
|
return 'Stock insuffisant : $availableQuantity/$totalQuantity disponible (demandé: $requestedQuantity)';
|
||||||
|
case ConflictType.containerFullyUsed:
|
||||||
|
return 'Boîte complète déjà utilisée';
|
||||||
|
case ConflictType.containerPartiallyUsed:
|
||||||
|
final count = conflictingChildrenIds?.length ?? 0;
|
||||||
|
return '$count équipement(s) de la boîte déjà utilisé(s)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service pour vérifier la disponibilité du matériel
|
/// Service pour vérifier la disponibilité du matériel
|
||||||
class EventAvailabilityService {
|
class EventAvailabilityService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
/// Vérifie si un équipement est disponible pour une plage de dates
|
/// Helper pour récupérer uniquement la liste d'événements
|
||||||
|
Future<List<Map<String, dynamic>>> _getEventsList() async {
|
||||||
|
final result = await _dataService.getEvents();
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
||||||
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
||||||
required String equipmentId,
|
required String equipmentId,
|
||||||
required String equipmentName,
|
required String equipmentName,
|
||||||
@@ -32,61 +84,54 @@ class EventAvailabilityService {
|
|||||||
final conflicts = <AvailabilityConflict>[];
|
final conflicts = <AvailabilityConflict>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[EventAvailabilityService] Checking availability for equipment: $equipmentId');
|
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
||||||
print('[EventAvailabilityService] Date range: $startDate - $endDate');
|
|
||||||
|
|
||||||
// Récupérer TOUS les événements (on filtre côté client car arrayContains avec objet ne marche pas)
|
// Utiliser la Cloud Function pour vérifier la disponibilité
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final result = await _dataService.checkEquipmentAvailability(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
print('[EventAvailabilityService] Found ${eventsSnapshot.docs.length} total events');
|
print('[EventAvailabilityService] Result for $equipmentId: $result');
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
final available = result['available'] as bool? ?? true;
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) {
|
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
|
||||||
continue; // Ignorer l'événement en cours d'édition
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!available) {
|
||||||
final event = EventModel.fromMap(doc.data(), doc.id);
|
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
||||||
|
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
|
||||||
|
|
||||||
// Vérifier si cet événement contient l'équipement recherché
|
for (final conflictData in conflictsData) {
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
final conflict = conflictData as Map<String, dynamic>;
|
||||||
(eq) => eq.equipmentId == equipmentId,
|
final eventId = conflict['eventId'] as String;
|
||||||
orElse: () => EventEquipment(equipmentId: ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si l'équipement est assigné et non retourné
|
// Le backend retourne déjà eventData
|
||||||
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
final eventData = conflict['eventData'] as Map<String, dynamic>?;
|
||||||
print('[EventAvailabilityService] Equipment $equipmentId found in event: ${event.name}');
|
|
||||||
|
|
||||||
// Vérifier le chevauchement des dates
|
|
||||||
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
|
||||||
final overlapDays = _calculateOverlapDays(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
event.startDateTime,
|
|
||||||
event.endDateTime,
|
|
||||||
);
|
|
||||||
|
|
||||||
print('[EventAvailabilityService] CONFLICT detected! Overlap: $overlapDays days');
|
|
||||||
|
|
||||||
|
if (eventData != null && eventData.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
conflicts.add(AvailabilityConflict(
|
conflicts.add(AvailabilityConflict(
|
||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
equipmentName: equipmentName,
|
equipmentName: equipmentName,
|
||||||
conflictingEvent: event,
|
conflictingEvent: event,
|
||||||
overlapDays: overlapDays,
|
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||||
));
|
));
|
||||||
|
print('[EventAvailabilityService] Added conflict with event ${event.name}');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventAvailabilityService] Error creating EventModel: $e');
|
||||||
|
print('[EventAvailabilityService] EventData: $eventData');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id}: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[EventAvailabilityService] Total conflicts found for $equipmentId: ${conflicts.length}');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error checking equipment availability: $e');
|
print('[EventAvailabilityService] Error checking availability: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +159,7 @@ class EventAvailabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return allConflicts;
|
return allConflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,30 +195,46 @@ class EventAvailabilityService {
|
|||||||
int reservedQuantity = 0;
|
int reservedQuantity = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer tous les événements (on filtre côté client)
|
// Récupérer tous les événements via Cloud Function
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final eventsData = await _getEventsList();
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) {
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(doc.data(), doc.id);
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
|
// Ignorer les événements annulés
|
||||||
|
if (event.status == EventStatus.canceled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer les dates réelles avec temps d'installation et démontage
|
||||||
|
final eventRealStartDate = event.startDateTime.subtract(
|
||||||
|
Duration(hours: event.installationTime),
|
||||||
|
);
|
||||||
|
final eventRealEndDate = event.endDateTime.add(
|
||||||
|
Duration(hours: event.disassemblyTime),
|
||||||
|
);
|
||||||
|
|
||||||
// Vérifier le chevauchement des dates
|
// Vérifier le chevauchement des dates
|
||||||
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
final assignedEquipment = event.assignedEquipment.firstWhere(
|
||||||
(eq) => eq.equipmentId == equipment.id,
|
(eq) => eq.equipmentId == equipment.id,
|
||||||
orElse: () => EventEquipment(equipmentId: ''),
|
orElse: () => EventEquipment(equipmentId: ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
// Si l'équipement est assigné, réserver la quantité
|
||||||
|
// (peu importe le statut de préparation/retour)
|
||||||
|
if (assignedEquipment.equipmentId.isNotEmpty) {
|
||||||
reservedQuantity += assignedEquipment.quantity;
|
reservedQuantity += assignedEquipment.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id} for quantity: $e');
|
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -181,5 +243,181 @@ class EventAvailabilityService {
|
|||||||
|
|
||||||
return totalQuantity - reservedQuantity;
|
return totalQuantity - reservedQuantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
|
||||||
|
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
|
||||||
|
required EquipmentModel equipment,
|
||||||
|
required int requestedQuantity,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
final conflicts = <AvailabilityConflict>[];
|
||||||
|
|
||||||
|
// Si équipement quantifiable (consommable/câble)
|
||||||
|
if (equipment.hasQuantity) {
|
||||||
|
final totalQuantity = equipment.totalQuantity ?? 0;
|
||||||
|
final availableQty = await getAvailableQuantity(
|
||||||
|
equipment: equipment,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
final reservedQty = totalQuantity - availableQty;
|
||||||
|
|
||||||
|
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
||||||
|
if (availableQty < requestedQuantity) {
|
||||||
|
// Trouver les événements qui réservent cette quantité
|
||||||
|
final eventsData = await _getEventsList();
|
||||||
|
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
|
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
||||||
|
final assignedEquipment = event.assignedEquipment.firstWhere(
|
||||||
|
(eq) => eq.equipmentId == equipment.id,
|
||||||
|
orElse: () => EventEquipment(equipmentId: ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
||||||
|
conflicts.add(AvailabilityConflict(
|
||||||
|
equipmentId: equipment.id,
|
||||||
|
equipmentName: equipment.name,
|
||||||
|
conflictingEvent: event,
|
||||||
|
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
|
||||||
|
type: ConflictType.insufficientQuantity,
|
||||||
|
totalQuantity: totalQuantity,
|
||||||
|
availableQuantity: availableQty,
|
||||||
|
requestedQuantity: requestedQuantity,
|
||||||
|
reservedQuantity: reservedQty,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Équipement non quantifiable : vérification classique
|
||||||
|
return await checkEquipmentAvailability(
|
||||||
|
equipmentId: equipment.id,
|
||||||
|
equipmentName: equipment.name,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'une boîte et de son contenu
|
||||||
|
Future<List<AvailabilityConflict>> checkContainerAvailability({
|
||||||
|
required ContainerModel container,
|
||||||
|
required List<EquipmentModel> containerEquipment,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
final conflicts = <AvailabilityConflict>[];
|
||||||
|
final conflictingChildrenIds = <String>[];
|
||||||
|
|
||||||
|
// Vérifier d'abord si la boîte complète est utilisée
|
||||||
|
final eventsData = await _getEventsList();
|
||||||
|
bool isContainerFullyUsed = false;
|
||||||
|
EventModel? containerConflictingEvent;
|
||||||
|
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
|
// Ignorer les événements annulés
|
||||||
|
if (event.status == EventStatus.canceled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer les dates réelles avec temps d'installation et démontage
|
||||||
|
final eventRealStartDate = event.startDateTime.subtract(
|
||||||
|
Duration(hours: event.installationTime),
|
||||||
|
);
|
||||||
|
final eventRealEndDate = event.endDateTime.add(
|
||||||
|
Duration(hours: event.disassemblyTime),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vérifier si cette boîte est assignée
|
||||||
|
if (event.assignedContainers.contains(container.id)) {
|
||||||
|
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
||||||
|
isContainerFullyUsed = true;
|
||||||
|
containerConflictingEvent = event;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContainerFullyUsed && containerConflictingEvent != null) {
|
||||||
|
// Boîte complète utilisée
|
||||||
|
conflicts.add(AvailabilityConflict(
|
||||||
|
equipmentId: container.id,
|
||||||
|
equipmentName: container.name,
|
||||||
|
conflictingEvent: containerConflictingEvent,
|
||||||
|
overlapDays: _calculateOverlapDays(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
containerConflictingEvent.startDateTime,
|
||||||
|
containerConflictingEvent.endDateTime,
|
||||||
|
),
|
||||||
|
type: ConflictType.containerFullyUsed,
|
||||||
|
containerId: container.id,
|
||||||
|
containerName: container.name,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Vérifier chaque équipement enfant individuellement
|
||||||
|
for (var equipment in containerEquipment) {
|
||||||
|
final equipmentConflicts = await checkEquipmentAvailability(
|
||||||
|
equipmentId: equipment.id,
|
||||||
|
equipmentName: equipment.name,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (equipmentConflicts.isNotEmpty) {
|
||||||
|
conflictingChildrenIds.add(equipment.id);
|
||||||
|
conflicts.addAll(equipmentConflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
|
||||||
|
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
|
||||||
|
conflicts.insert(
|
||||||
|
0,
|
||||||
|
AvailabilityConflict(
|
||||||
|
equipmentId: container.id,
|
||||||
|
equipmentName: container.name,
|
||||||
|
conflictingEvent: conflicts.first.conflictingEvent,
|
||||||
|
overlapDays: conflicts.first.overlapDays,
|
||||||
|
type: ConflictType.containerPartiallyUsed,
|
||||||
|
containerId: container.id,
|
||||||
|
containerName: container.name,
|
||||||
|
conflictingChildrenIds: conflictingChildrenIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -7,32 +6,46 @@ import 'dart:convert';
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/alert_service.dart';
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
class EventFormService {
|
class EventFormService {
|
||||||
|
static final ApiService _apiService = apiService;
|
||||||
|
static final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations - Utilise l'API (sécurisé avec permissions côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<List<EventTypeModel>> fetchEventTypes() async {
|
static Future<List<EventTypeModel>> fetchEventTypes() async {
|
||||||
developer.log('Fetching event types from Firestore...', name: 'EventFormService');
|
developer.log('Fetching event types via API...', name: 'EventFormService');
|
||||||
try {
|
try {
|
||||||
final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get();
|
final eventTypesData = await _dataService.getEventTypes();
|
||||||
final eventTypes = snapshot.docs.map((doc) => EventTypeModel.fromMap(doc.data(), doc.id)).toList();
|
final eventTypes = eventTypesData.map((data) => EventTypeModel.fromMap(data, data['id'] as String)).toList();
|
||||||
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
|
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
|
||||||
return eventTypes;
|
return eventTypes;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
|
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
|
||||||
throw Exception("Could not load event types. Please check Firestore permissions.");
|
throw Exception("Could not load event types. Please check permissions.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<UserModel>> fetchUsers() async {
|
static Future<List<UserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await FirebaseFirestore.instance.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
return snapshot.docs.map((doc) => UserModel.fromMap(doc.data(), doc.id)).toList();
|
return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error fetching users', name: 'EventFormService', error: e);
|
developer.log('Error fetching users', name: 'EventFormService', error: e);
|
||||||
throw Exception("Could not load users.");
|
throw Exception("Could not load users.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STORAGE - Reste inchangé (déjà via Cloud Function)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
|
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
|
||||||
List<Map<String, String>> uploadedFiles = [];
|
List<Map<String, String>> uploadedFiles = [];
|
||||||
|
|
||||||
@@ -58,7 +71,7 @@ class EventFormService {
|
|||||||
required String sourcePath,
|
required String sourcePath,
|
||||||
required String destinationPath,
|
required String destinationPath,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
final url = Uri.parse('https://europe-west9-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
final idToken = await user?.getIdToken();
|
final idToken = await user?.getIdToken();
|
||||||
|
|
||||||
@@ -90,14 +103,81 @@ class EventFormService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<String> createEvent(EventModel event) async {
|
static Future<String> createEvent(EventModel event) async {
|
||||||
final docRef = await FirebaseFirestore.instance.collection('events').add(event.toMap());
|
try {
|
||||||
return docRef.id;
|
final result = await _apiService.call('createEvent', event.toMap());
|
||||||
|
final eventId = result['id'] as String;
|
||||||
|
|
||||||
|
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||||
|
try {
|
||||||
|
await AlertService().createEventCreatedAlert(
|
||||||
|
eventId: eventId,
|
||||||
|
eventName: event.name,
|
||||||
|
eventDate: event.startDateTime,
|
||||||
|
);
|
||||||
|
developer.log('Alert created for new event: $eventId', name: 'EventFormService');
|
||||||
|
} catch (alertError) {
|
||||||
|
// Ne pas bloquer la création de l'événement si l'alerte échoue
|
||||||
|
developer.log('Warning: Could not create alert for event',
|
||||||
|
name: 'EventFormService',
|
||||||
|
error: alertError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId;
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error creating event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateEvent(EventModel event) async {
|
static Future<void> updateEvent(EventModel event) async {
|
||||||
final docRef = FirebaseFirestore.instance.collection('events').doc(event.id);
|
try {
|
||||||
await docRef.update(event.toMap());
|
if (event.id.isEmpty) {
|
||||||
|
throw Exception("Cannot update event: Event ID is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log('Updating event with ID: ${event.id}', name: 'EventFormService');
|
||||||
|
|
||||||
|
final eventData = event.toMap();
|
||||||
|
eventData['eventId'] = event.id;
|
||||||
|
await _apiService.call('updateEvent', eventData);
|
||||||
|
|
||||||
|
developer.log('Event updated successfully', name: 'EventFormService');
|
||||||
|
|
||||||
|
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||||
|
try {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
if (currentUserId != null) {
|
||||||
|
await AlertService().createEventModifiedAlert(
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.name,
|
||||||
|
modification: 'Informations modifiées',
|
||||||
|
);
|
||||||
|
developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService');
|
||||||
|
}
|
||||||
|
} catch (alertError) {
|
||||||
|
// Ne pas bloquer la modification de l'événement si l'alerte échoue
|
||||||
|
developer.log('Warning: Could not create alert for event modification',
|
||||||
|
name: 'EventFormService',
|
||||||
|
error: alertError);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error updating event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteEvent(String eventId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error deleting event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, String>>> moveFilesToEvent(
|
static Future<List<Map<String, String>>> moveFilesToEvent(
|
||||||
@@ -135,9 +215,22 @@ class EventFormService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
|
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
|
||||||
await FirebaseFirestore.instance
|
try {
|
||||||
.collection('events')
|
if (eventId.isEmpty) {
|
||||||
.doc(eventId)
|
throw Exception("Event ID cannot be empty");
|
||||||
.update({'documents': documents});
|
}
|
||||||
|
|
||||||
|
developer.log('Updating event documents for ID: $eventId (${documents.length} documents)', name: 'EventFormService');
|
||||||
|
|
||||||
|
await _apiService.call('updateEvent', {
|
||||||
|
'eventId': eventId,
|
||||||
|
'documents': documents,
|
||||||
|
});
|
||||||
|
|
||||||
|
developer.log('Event documents updated successfully', name: 'EventFormService');
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error updating event documents', name: 'EventFormService', error: e);
|
||||||
|
throw Exception("Could not update event documents.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,17 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:em2rp/services/equipment_status_calculator.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
|
||||||
|
|
||||||
class EventPreparationService {
|
class EventPreparationService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
final EquipmentService _equipmentService = EquipmentService();
|
|
||||||
|
|
||||||
// Collection references
|
|
||||||
CollectionReference get _eventsCollection => _firestore.collection('events');
|
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipment');
|
|
||||||
|
|
||||||
// === PRÉPARATION ===
|
// === PRÉPARATION ===
|
||||||
|
|
||||||
/// Valider un équipement individuel en préparation
|
/// Valider un équipement individuel en préparation
|
||||||
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
|
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentPreparation', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
'equipmentId': equipmentId,
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour le statut de l'équipement dans la liste
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(isPrepared: true);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment preparation: $e');
|
print('Error validating equipment preparation: $e');
|
||||||
@@ -41,58 +22,30 @@ class EventPreparationService {
|
|||||||
/// Valider tous les équipements en préparation
|
/// Valider tous les équipements en préparation
|
||||||
Future<void> validateAllPreparation(String eventId) async {
|
Future<void> validateAllPreparation(String eventId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllPreparation', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer tous les équipements comme préparés
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
return eq.copyWith(isPrepared: true);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "inUse"
|
// Invalider le cache des statuts d'équipement
|
||||||
for (var equipment in event.assignedEquipment) {
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating all preparation: $e');
|
print('Error validating all preparation: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finaliser la préparation avec des équipements manquants
|
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
|
||||||
|
// si nécessaire dans le futur
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Deprecated('Use Cloud Functions instead')
|
||||||
Future<void> completePreparationWithMissing(
|
Future<void> completePreparationWithMissing(
|
||||||
String eventId,
|
String eventId,
|
||||||
List<String> missingEquipmentIds,
|
List<String> missingEquipmentIds,
|
||||||
) async {
|
) async {
|
||||||
try {
|
throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme complété avec manquants
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'preparationStatus': preparationStatusToString(PreparationStatus.completedWithMissing),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements préparés à "inUse"
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
if (equipment.isPrepared && !missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error completing preparation with missing: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// === RETOUR ===
|
// === RETOUR ===
|
||||||
|
|
||||||
@@ -104,43 +57,11 @@ class EventPreparationService {
|
|||||||
int? returnedQuantity,
|
int? returnedQuantity,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentReturn', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
'equipmentId': equipmentId,
|
||||||
}
|
if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
|
||||||
|
|
||||||
// Mettre à jour le statut de l'équipement dans la liste
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQuantity,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour le stock si c'est un consommable
|
|
||||||
if (returnedQuantity != null) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.hasQuantity) {
|
|
||||||
final currentAvailable = equipment.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + returnedQuantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment return: $e');
|
print('Error validating equipment return: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -153,217 +74,31 @@ class EventPreparationService {
|
|||||||
Map<String, int>? returnedQuantities,
|
Map<String, int>? returnedQuantities,
|
||||||
]) async {
|
]) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllReturn', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer tous les équipements comme retournés
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
final returnedQty = returnedQuantities?[eq.equipmentId] ??
|
|
||||||
eq.returnedQuantity ??
|
|
||||||
eq.quantity;
|
|
||||||
return eq.copyWith(
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQty,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'returnStatus': returnStatusToString(ReturnStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "available" et gérer les stocks
|
// Invalider le cache des statuts d'équipement
|
||||||
for (var equipment in updatedEquipment) {
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipment.equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating all return: $e');
|
print('Error validating all return: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finaliser le retour avec des équipements manquants
|
/*
|
||||||
|
@Deprecated('Use Cloud Functions instead')
|
||||||
Future<void> completeReturnWithMissing(
|
Future<void> completeReturnWithMissing(
|
||||||
String eventId,
|
String eventId,
|
||||||
List<String> missingEquipmentIds,
|
List<String> missingEquipmentIds,
|
||||||
) async {
|
) async {
|
||||||
try {
|
throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme complété avec manquants
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'returnStatus': returnStatusToString(ReturnStatus.completedWithMissing),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements retournés à "available"
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipment.equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
// Marquer comme perdu
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error completing return with missing: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === HELPERS ===
|
// Les méthodes helper suivantes étaient uniquement utilisées par les méthodes deprecated ci-dessus.
|
||||||
|
// Elles ont été supprimées car elles accédaient directement à Firestore.
|
||||||
/// Mettre à jour le statut d'un équipement
|
// Si ces fonctionnalités sont nécessaires à l'avenir, elles doivent être implémentées
|
||||||
Future<void> updateEquipmentStatus(String equipmentId, EquipmentStatus status) async {
|
// via des Cloud Functions pour respecter l'architecture.
|
||||||
try {
|
*/
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'status': equipmentStatusToString(status),
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating equipment status: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer un événement
|
|
||||||
Future<EventModel?> _getEvent(String eventId) async {
|
|
||||||
try {
|
|
||||||
final doc = await _eventsCollection.doc(eventId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ajouter un équipement à un événement
|
|
||||||
Future<void> addEquipmentToEvent(
|
|
||||||
String eventId,
|
|
||||||
String equipmentId, {
|
|
||||||
int quantity = 1,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier que l'équipement n'est pas déjà ajouté
|
|
||||||
final alreadyAdded = event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId);
|
|
||||||
if (alreadyAdded) {
|
|
||||||
throw Exception('Equipment already added to event');
|
|
||||||
}
|
|
||||||
|
|
||||||
final newEquipment = EventEquipment(
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
quantity: quantity,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedEquipment = [...event.assignedEquipment, newEquipment];
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Décrémenter le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable - quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error adding equipment to event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retirer un équipement d'un événement
|
|
||||||
Future<void> removeEquipmentFromEvent(String eventId, String equipmentId) async {
|
|
||||||
try {
|
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
final equipmentToRemove = event.assignedEquipment.firstWhere(
|
|
||||||
(eq) => eq.equipmentId == equipmentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedEquipment = event.assignedEquipment
|
|
||||||
.where((eq) => eq.equipmentId != equipmentId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipmentToRemove.quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error removing equipment from event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
|
import 'package:em2rp/config/app_version.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class IcsExportService {
|
class IcsExportService {
|
||||||
/// Génère un fichier ICS à partir d'un événement
|
/// Génère un fichier ICS à partir d'un événement
|
||||||
static Future<String> generateIcsContent(EventModel event) async {
|
///
|
||||||
|
/// [eventTypeName] : Nom du type d'événement (optionnel, sera résolu si non fourni)
|
||||||
|
/// [userNames] : Map des IDs utilisateurs vers leurs noms complets (optionnel)
|
||||||
|
/// [optionNames] : Map des IDs options vers leurs noms (optionnel)
|
||||||
|
static Future<String> generateIcsContent(
|
||||||
|
EventModel event, {
|
||||||
|
String? eventTypeName,
|
||||||
|
Map<String, String>? userNames,
|
||||||
|
Map<String, String>? optionNames,
|
||||||
|
}) async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
||||||
|
|
||||||
// Récupérer les informations supplémentaires
|
// Récupérer les informations supplémentaires
|
||||||
final eventTypeName = await _getEventTypeName(event.eventTypeId);
|
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
||||||
final workforce = await _getWorkforceDetails(event.workforce);
|
final workforce = await _getWorkforceDetails(event.workforce, userNames: userNames);
|
||||||
final optionsWithNames = await _getOptionsDetails(event.options);
|
final optionsWithNames = await _getOptionsDetails(event.options, optionNames: optionNames);
|
||||||
|
|
||||||
// Formater les dates au format ICS (UTC)
|
// Formater les dates au format ICS (UTC)
|
||||||
final startDate = _formatDateForIcs(event.startDateTime);
|
final startDate = _formatDateForIcs(event.startDateTime);
|
||||||
final endDate = _formatDateForIcs(event.endDateTime);
|
final endDate = _formatDateForIcs(event.endDateTime);
|
||||||
|
|
||||||
// Construire la description détaillée
|
// Construire la description détaillée
|
||||||
final description = _buildDescription(event, eventTypeName, workforce, optionsWithNames);
|
final description = _buildDescription(event, resolvedEventTypeName, workforce, optionsWithNames);
|
||||||
|
|
||||||
// Générer un UID unique basé sur l'ID de l'événement
|
// Générer un UID unique basé sur l'ID de l'événement
|
||||||
final uid = 'em2rp-${event.id}@em2rp.app';
|
final uid = 'em2rp-${event.id}@em2rp.app';
|
||||||
@@ -38,52 +48,64 @@ SUMMARY:${_escapeIcsText(event.name)}
|
|||||||
DESCRIPTION:${_escapeIcsText(description)}
|
DESCRIPTION:${_escapeIcsText(description)}
|
||||||
LOCATION:${_escapeIcsText(event.address)}
|
LOCATION:${_escapeIcsText(event.address)}
|
||||||
STATUS:${_getEventStatus(event.status)}
|
STATUS:${_getEventStatus(event.status)}
|
||||||
CATEGORIES:${_escapeIcsText(eventTypeName)}
|
CATEGORIES:${_escapeIcsText(resolvedEventTypeName)}
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR''';
|
END:VCALENDAR''';
|
||||||
|
|
||||||
return icsContent;
|
return icsContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère le nom du type d'événement
|
/// Récupère le nom du type d'événement depuis EventModel (déjà chargé)
|
||||||
|
/// Note: Les eventTypes sont maintenant chargés via Cloud Function dans l'EventModel
|
||||||
static Future<String> _getEventTypeName(String eventTypeId) async {
|
static Future<String> _getEventTypeName(String eventTypeId) async {
|
||||||
if (eventTypeId.isEmpty) return 'Non spécifié';
|
if (eventTypeId.isEmpty) return 'Non spécifié';
|
||||||
|
|
||||||
try {
|
// Les eventTypes sont publics et déjà chargés dans l'app via Cloud Function
|
||||||
final doc = await FirebaseFirestore.instance
|
// On retourne simplement l'ID, le nom sera résolu par l'app
|
||||||
.collection('eventTypes')
|
|
||||||
.doc(eventTypeId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
|
||||||
return doc.data()?['name'] as String? ?? eventTypeId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Erreur lors de la récupération du type d\'événement: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
return eventTypeId;
|
return eventTypeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les détails de la main d'œuvre
|
/// Récupère les détails de la main d'œuvre
|
||||||
static Future<List<String>> _getWorkforceDetails(List<DocumentReference> workforce) async {
|
/// Si userNames est fourni, utilise les noms déjà résolus pour de meilleures performances
|
||||||
|
static Future<List<String>> _getWorkforceDetails(
|
||||||
|
List<dynamic> workforce, {
|
||||||
|
Map<String, String>? userNames,
|
||||||
|
}) async {
|
||||||
final List<String> workforceNames = [];
|
final List<String> workforceNames = [];
|
||||||
|
|
||||||
for (final ref in workforce) {
|
for (final ref in workforce) {
|
||||||
try {
|
try {
|
||||||
final doc = await ref.get();
|
// Si c'est déjà une Map avec les données, l'utiliser directement
|
||||||
if (doc.exists) {
|
if (ref is Map<String, dynamic>) {
|
||||||
final data = doc.data() as Map<String, dynamic>?;
|
final firstName = ref['firstName'] ?? '';
|
||||||
if (data != null) {
|
final lastName = ref['lastName'] ?? '';
|
||||||
final firstName = data['firstName'] ?? '';
|
if (firstName.isNotEmpty || lastName.isNotEmpty) {
|
||||||
final lastName = data['lastName'] ?? '';
|
workforceNames.add('$firstName $lastName'.trim());
|
||||||
if (firstName.isNotEmpty || lastName.isNotEmpty) {
|
}
|
||||||
workforceNames.add('$firstName $lastName'.trim());
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si c'est un String (UID) et qu'on a les noms résolus, les utiliser
|
||||||
|
if (ref is String) {
|
||||||
|
if (userNames != null && userNames.containsKey(ref)) {
|
||||||
|
workforceNames.add(userNames[ref]!);
|
||||||
|
} else {
|
||||||
|
workforceNames.add('Utilisateur $ref');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si c'est une DocumentReference
|
||||||
|
if (ref is DocumentReference) {
|
||||||
|
final userId = ref.id;
|
||||||
|
if (userNames != null && userNames.containsKey(userId)) {
|
||||||
|
workforceNames.add(userNames[userId]!);
|
||||||
|
} else {
|
||||||
|
workforceNames.add('Utilisateur $userId');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de la récupération des détails utilisateur: $e');
|
print('Erreur lors du traitement des détails utilisateur: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,46 +113,32 @@ END:VCALENDAR''';
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les détails des options
|
/// Récupère les détails des options
|
||||||
static Future<List<Map<String, dynamic>>> _getOptionsDetails(List<Map<String, dynamic>> options) async {
|
/// Si optionNames est fourni, utilise les noms déjà résolus
|
||||||
|
static Future<List<Map<String, dynamic>>> _getOptionsDetails(
|
||||||
|
List<Map<String, dynamic>> options, {
|
||||||
|
Map<String, String>? optionNames,
|
||||||
|
}) async {
|
||||||
final List<Map<String, dynamic>> optionsWithNames = [];
|
final List<Map<String, dynamic>> optionsWithNames = [];
|
||||||
|
|
||||||
for (final option in options) {
|
for (final option in options) {
|
||||||
try {
|
try {
|
||||||
|
String optionName = option['name'] ?? 'Option inconnue';
|
||||||
|
|
||||||
|
// Si on a l'ID de l'option et les noms résolus, utiliser le nom résolu
|
||||||
final optionId = option['id'] ?? option['optionId'];
|
final optionId = option['id'] ?? option['optionId'];
|
||||||
if (optionId == null || optionId.toString().isEmpty) {
|
if (optionId != null && optionNames != null && optionNames.containsKey(optionId)) {
|
||||||
// Si pas d'ID, garder le nom tel quel
|
optionName = optionNames[optionId]!;
|
||||||
optionsWithNames.add({
|
} else if (optionName == 'Option inconnue' && optionId != null) {
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
optionName = 'Option $optionId';
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le nom depuis Firestore
|
|
||||||
final doc = await FirebaseFirestore.instance
|
|
||||||
.collection('options')
|
|
||||||
.doc(optionId.toString())
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
|
||||||
final data = doc.data();
|
|
||||||
optionsWithNames.add({
|
|
||||||
'name': data?['name'] ?? option['name'] ?? 'Option inconnue',
|
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Document n'existe pas, garder le nom de l'option
|
|
||||||
optionsWithNames.add({
|
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Erreur lors de la récupération des détails option: $e');
|
|
||||||
optionsWithNames.add({
|
optionsWithNames.add({
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
'name': optionName,
|
||||||
'quantity': option['quantity'],
|
'quantity': option['quantity'],
|
||||||
|
'price': option['price'],
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print('Erreur lors du traitement des options: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +230,7 @@ END:VCALENDAR''';
|
|||||||
// Lien vers l'application
|
// Lien vers l'application
|
||||||
buffer.writeln('');
|
buffer.writeln('');
|
||||||
buffer.writeln('---');
|
buffer.writeln('---');
|
||||||
buffer.writeln('Géré par EM2RP Event Manager');
|
buffer.writeln('Généré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,32 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
|
||||||
|
|
||||||
class MaintenanceService {
|
class MaintenanceService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
final EquipmentService _equipmentService = EquipmentService();
|
|
||||||
|
|
||||||
// Collection references
|
|
||||||
CollectionReference get _maintenancesCollection => _firestore.collection('maintenances');
|
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipment');
|
|
||||||
CollectionReference get _alertsCollection => _firestore.collection('alerts');
|
|
||||||
|
|
||||||
// CRUD Operations
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Créer une nouvelle maintenance
|
/// Créer une nouvelle maintenance (via Cloud Function)
|
||||||
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||||
try {
|
try {
|
||||||
await _maintenancesCollection.doc(maintenance.id).set(maintenance.toMap());
|
await _apiService.call('createMaintenance', maintenance.toMap());
|
||||||
|
// Note: La Cloud Function gère maintenant la mise à jour des équipements et la création des alertes
|
||||||
// Mettre à jour les équipements concernés
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _updateEquipmentMaintenanceList(equipmentId, maintenance.id);
|
|
||||||
|
|
||||||
// Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte
|
|
||||||
if (maintenance.scheduledDate.isBefore(DateTime.now().add(const Duration(days: 7)))) {
|
|
||||||
await _createMaintenanceAlert(equipmentId, maintenance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating maintenance: $e');
|
print('Error creating maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour une maintenance
|
/// Mettre à jour une maintenance (via Cloud Function)
|
||||||
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
await _apiService.call('updateMaintenance', {
|
||||||
await _maintenancesCollection.doc(id).update(data);
|
'maintenanceId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating maintenance: $e');
|
print('Error updating maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -49,21 +36,10 @@ class MaintenanceService {
|
|||||||
/// Supprimer une maintenance
|
/// Supprimer une maintenance
|
||||||
Future<void> deleteMaintenance(String id) async {
|
Future<void> deleteMaintenance(String id) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer la maintenance pour connaître les équipements
|
await _apiService.call('deleteMaintenance', {
|
||||||
final doc = await _maintenancesCollection.doc(id).get();
|
'maintenanceId': id,
|
||||||
if (doc.exists) {
|
});
|
||||||
final maintenance = MaintenanceModel.fromMap(
|
// Note: La Cloud Function gère la mise à jour des équipements
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retirer la maintenance des équipements
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _removeMaintenanceFromEquipment(equipmentId, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _maintenancesCollection.doc(id).delete();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting maintenance: $e');
|
print('Error deleting maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -73,54 +49,54 @@ class MaintenanceService {
|
|||||||
/// Récupérer une maintenance par ID
|
/// Récupérer une maintenance par ID
|
||||||
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _maintenancesCollection.doc(id).get();
|
final response = await _apiService.call('getMaintenances', {
|
||||||
if (doc.exists) {
|
'maintenanceId': id,
|
||||||
return MaintenanceModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
});
|
||||||
}
|
|
||||||
return null;
|
final maintenances = (response['maintenances'] as List?)
|
||||||
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return maintenances?.firstWhere(
|
||||||
|
(m) => m.id == id,
|
||||||
|
orElse: () => throw Exception('Maintenance not found'),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenance: $e');
|
print('Error getting maintenance: $e');
|
||||||
rethrow;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer l'historique des maintenances pour un équipement
|
/// Récupérer l'historique des maintenances pour un équipement
|
||||||
Stream<List<MaintenanceModel>> getMaintenances(String equipmentId) {
|
Future<List<MaintenanceModel>> getMaintenancesByEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
return _maintenancesCollection
|
final response = await _apiService.call('getMaintenances', {
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
'equipmentId': equipmentId,
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.snapshots()
|
|
||||||
.map((snapshot) {
|
|
||||||
return snapshot.docs
|
|
||||||
.map((doc) => MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final maintenances = (response['maintenances'] as List?)
|
||||||
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
return maintenances;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming maintenances: $e');
|
print('Error getting maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer toutes les maintenances
|
/// Récupérer toutes les maintenances
|
||||||
Stream<List<MaintenanceModel>> getAllMaintenances() {
|
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||||
try {
|
try {
|
||||||
return _maintenancesCollection
|
final response = await _apiService.call('getMaintenances', {});
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.snapshots()
|
final maintenances = (response['maintenances'] as List?)
|
||||||
.map((snapshot) {
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
return snapshot.docs
|
.toList() ?? [];
|
||||||
.map((doc) => MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
return maintenances;
|
||||||
doc.id,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming all maintenances: $e');
|
print('Error getting all maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,30 +104,11 @@ class MaintenanceService {
|
|||||||
/// Marquer une maintenance comme complétée
|
/// Marquer une maintenance comme complétée
|
||||||
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
|
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
|
||||||
try {
|
try {
|
||||||
final updateData = <String, dynamic>{
|
await _apiService.call('completeMaintenance', {
|
||||||
'completedDate': Timestamp.fromDate(DateTime.now()),
|
'maintenanceId': id,
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
if (performedBy != null) 'performedBy': performedBy,
|
||||||
};
|
if (cost != null) 'cost': cost,
|
||||||
|
});
|
||||||
if (performedBy != null) {
|
|
||||||
updateData['performedBy'] = performedBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cost != null) {
|
|
||||||
updateData['cost'] = cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateMaintenance(id, updateData);
|
|
||||||
|
|
||||||
// Mettre à jour la date de dernière maintenance des équipements
|
|
||||||
final maintenance = await getMaintenanceById(id);
|
|
||||||
if (maintenance != null) {
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'lastMaintenanceDate': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error completing maintenance: $e');
|
print('Error completing maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -161,121 +118,10 @@ class MaintenanceService {
|
|||||||
/// Vérifier les maintenances à venir et créer des alertes
|
/// Vérifier les maintenances à venir et créer des alertes
|
||||||
Future<void> checkUpcomingMaintenances() async {
|
Future<void> checkUpcomingMaintenances() async {
|
||||||
try {
|
try {
|
||||||
final sevenDaysFromNow = DateTime.now().add(const Duration(days: 7));
|
await _apiService.call('checkUpcomingMaintenances', {});
|
||||||
|
|
||||||
// Récupérer les maintenances planifiées dans les 7 prochains jours
|
|
||||||
final maintenancesQuery = await _maintenancesCollection
|
|
||||||
.where('scheduledDate', isLessThanOrEqualTo: Timestamp.fromDate(sevenDaysFromNow))
|
|
||||||
.where('completedDate', isNull: true)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in maintenancesQuery.docs) {
|
|
||||||
final maintenance = MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _createMaintenanceAlert(equipmentId, maintenance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking upcoming maintenances: $e');
|
print('Error checking upcoming maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une alerte de maintenance à venir
|
|
||||||
Future<void> _createMaintenanceAlert(String equipmentId, MaintenanceModel maintenance) async {
|
|
||||||
try {
|
|
||||||
// Vérifier si une alerte existe déjà
|
|
||||||
final existingAlerts = await _alertsCollection
|
|
||||||
.where('equipmentId', isEqualTo: equipmentId)
|
|
||||||
.where('type', isEqualTo: alertTypeToString(AlertType.maintenanceDue))
|
|
||||||
.where('isRead', isEqualTo: false)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
// Vérifier si l'alerte concerne la même maintenance
|
|
||||||
bool alertExists = false;
|
|
||||||
for (var alertDoc in existingAlerts.docs) {
|
|
||||||
final alertData = alertDoc.data() as Map<String, dynamic>;
|
|
||||||
if (alertData['message']?.contains(maintenance.name) ?? false) {
|
|
||||||
alertExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!alertExists) {
|
|
||||||
// Récupérer l'équipement pour le nom
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
String equipmentName = equipmentId;
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = equipmentDoc.data() as Map<String, dynamic>;
|
|
||||||
equipmentName = equipmentData['name'] ?? equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
final daysUntil = maintenance.scheduledDate.difference(DateTime.now()).inDays;
|
|
||||||
final alert = AlertModel(
|
|
||||||
id: _alertsCollection.doc().id,
|
|
||||||
type: AlertType.maintenanceDue,
|
|
||||||
message: 'Maintenance "${maintenance.name}" prévue dans $daysUntil jour(s) pour $equipmentName',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _alertsCollection.doc(alert.id).set(alert.toMap());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error creating maintenance alert: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mettre à jour la liste des maintenances d'un équipement
|
|
||||||
Future<void> _updateEquipmentMaintenanceList(String equipmentId, String maintenanceId) async {
|
|
||||||
try {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
|
|
||||||
if (!updatedMaintenanceIds.contains(maintenanceId)) {
|
|
||||||
updatedMaintenanceIds.add(maintenanceId);
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'maintenanceIds': updatedMaintenanceIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating equipment maintenance list: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retirer une maintenance de la liste d'un équipement
|
|
||||||
Future<void> _removeMaintenanceFromEquipment(String equipmentId, String maintenanceId) async {
|
|
||||||
try {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
|
|
||||||
updatedMaintenanceIds.remove(maintenanceId);
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'maintenanceIds': updatedMaintenanceIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error removing maintenance from equipment: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ class PDFGeneratorConfig {
|
|||||||
itemsPerPage: 50,
|
itemsPerPage: 50,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4 colonnes x 10 lignes = 40 étiquettes
|
||||||
static const medium = PDFGeneratorConfig(
|
static const medium = PDFGeneratorConfig(
|
||||||
qrCodeSize: 250,
|
qrCodeSize: 150, // Réduit légèrement pour entrer dans 25.4mm de haut
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 40,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2 colonnes x 5 lignes = 10 étiquettes
|
||||||
static const large = PDFGeneratorConfig(
|
static const large = PDFGeneratorConfig(
|
||||||
qrCodeSize: 300,
|
qrCodeSize: 300,
|
||||||
itemsPerPage: 12,
|
itemsPerPage: 10,
|
||||||
);
|
);
|
||||||
|
|
||||||
static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
|
static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
|
||||||
@@ -47,7 +49,6 @@ class PDFGeneratorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
|
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
|
||||||
/// Remplace PDFGeneratorService, ContainerPDFGeneratorService et UnifiedPDFGeneratorService
|
|
||||||
class PDFService {
|
class PDFService {
|
||||||
static Uint8List? _cachedLogoBytes;
|
static Uint8List? _cachedLogoBytes;
|
||||||
static bool _logoLoadAttempted = false;
|
static bool _logoLoadAttempted = false;
|
||||||
@@ -71,13 +72,6 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Génère un PDF avec QR codes pour n'importe quel type d'objet
|
/// Génère un PDF avec QR codes pour n'importe quel type d'objet
|
||||||
///
|
|
||||||
/// [items] : Liste des objets à générer
|
|
||||||
/// [format] : Format des étiquettes (small, medium, large)
|
|
||||||
/// [getId] : Fonction pour obtenir l'ID unique
|
|
||||||
/// [getTitle] : Fonction pour obtenir le titre (optionnel)
|
|
||||||
/// [getDetails] : Fonction pour obtenir les détails (optionnel, seulement pour large)
|
|
||||||
/// [onProgress] : Callback de progression (optionnel)
|
|
||||||
static Future<Uint8List> generatePDF<T>({
|
static Future<Uint8List> generatePDF<T>({
|
||||||
required List<T> items,
|
required List<T> items,
|
||||||
required QRLabelFormat format,
|
required QRLabelFormat format,
|
||||||
@@ -93,8 +87,8 @@ class PDFService {
|
|||||||
final config = PDFGeneratorConfig.fromFormat(format);
|
final config = PDFGeneratorConfig.fromFormat(format);
|
||||||
final pdf = pw.Document();
|
final pdf = pw.Document();
|
||||||
|
|
||||||
// Pré-charger le logo pour format large
|
// Pré-charger le logo pour formats medium et large
|
||||||
if (format == QRLabelFormat.large) {
|
if (format == QRLabelFormat.medium || format == QRLabelFormat.large) {
|
||||||
await _ensureLogoLoaded();
|
await _ensureLogoLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,16 +118,16 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// PETITS LABELS (2x2 cm, 20 par page)
|
// PETITS LABELS (Original: 2x2 cm approx)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
static void _addSmallLabels<T>(
|
static void _addSmallLabels<T>(
|
||||||
pw.Document pdf,
|
pw.Document pdf,
|
||||||
List<T> items,
|
List<T> items,
|
||||||
String Function(T) getId,
|
String Function(T) getId,
|
||||||
List<Uint8List> qrImages,
|
List<Uint8List> qrImages,
|
||||||
PDFGeneratorConfig config,
|
PDFGeneratorConfig config,
|
||||||
) {
|
) {
|
||||||
const qrSize = 56.69; // 2cm
|
const qrSize = 56.69; // ~2cm
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
@@ -169,19 +163,30 @@ class PDFService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ========================================================================
|
||||||
// ========================================================================
|
// LABELS MOYENS (49 x 26 mm | 4 colonnes, 10 lignes)
|
||||||
// LABELS MOYENS (4x4 cm, 6 par page)
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
static void _addMediumLabels<T>(
|
static void _addMediumLabels<T>(
|
||||||
pw.Document pdf,
|
pw.Document pdf,
|
||||||
List<T> items,
|
List<T> items,
|
||||||
String Function(T) getId,
|
String Function(T) getId,
|
||||||
String Function(T)? getTitle,
|
String Function(T)? getTitle,
|
||||||
List<Uint8List> qrImages,
|
List<Uint8List> qrImages,
|
||||||
PDFGeneratorConfig config,
|
PDFGeneratorConfig config,
|
||||||
) {
|
) {
|
||||||
const qrSize = 113.39; // 4cm
|
// 1. Dimensions exactes des étiquettes
|
||||||
|
const double labelWidth = 50 * PdfPageFormat.mm;
|
||||||
|
const double labelHeight = 26.0 * PdfPageFormat.mm;
|
||||||
|
|
||||||
|
// 2. Calcul du centrage manuel
|
||||||
|
// Marge théorique = (210mm - (49*4)) / 2 = 7mm
|
||||||
|
// CORRECTION : On enlève 1.5mm pour réduire la marge de gauche (décalage vers la gauche)
|
||||||
|
const double horizontalCorrection = PdfPageFormat.mm;
|
||||||
|
|
||||||
|
final double leftMargin = ((PdfPageFormat.a4.width - (labelWidth * 4)) / 2) + horizontalCorrection;
|
||||||
|
|
||||||
|
// Centrage vertical standard
|
||||||
|
final double topMargin = (PdfPageFormat.a4.height - (labelHeight * 10)) / 2 -0.75;
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
@@ -190,130 +195,56 @@ class PDFService {
|
|||||||
pdf.addPage(
|
pdf.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
pageFormat: PdfPageFormat.a4,
|
pageFormat: PdfPageFormat.a4,
|
||||||
margin: const pw.EdgeInsets.all(20),
|
// 3. Application des marges calculées (plus de pw.Center)
|
||||||
build: (_) => pw.Wrap(
|
margin: pw.EdgeInsets.only(
|
||||||
spacing: 20,
|
left: leftMargin,
|
||||||
runSpacing: 20,
|
top: topMargin,
|
||||||
children: List.generate(pageItems.length, (i) {
|
right: 0,
|
||||||
return pw.Container(
|
bottom: 0
|
||||||
width: qrSize,
|
|
||||||
height: qrSize + 30,
|
|
||||||
child: pw.Column(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
pw.Image(pw.MemoryImage(pageQRs[i])),
|
|
||||||
pw.SizedBox(height: 4),
|
|
||||||
pw.Text(
|
|
||||||
getId(pageItems[i]),
|
|
||||||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
|
||||||
textAlign: pw.TextAlign.center,
|
|
||||||
),
|
|
||||||
if (getTitle != null) ...[
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
|
||||||
_truncate(getTitle(pageItems[i]), 25),
|
|
||||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
|
||||||
textAlign: pw.TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// GRANDS LABELS (avec détails, 10 par page)
|
|
||||||
// ========================================================================
|
|
||||||
static void _addLargeLabels<T>(
|
|
||||||
pw.Document pdf,
|
|
||||||
List<T> items,
|
|
||||||
String Function(T) getId,
|
|
||||||
String Function(T)? getTitle,
|
|
||||||
List<String> Function(T)? getDetails,
|
|
||||||
List<Uint8List> qrImages,
|
|
||||||
PDFGeneratorConfig config,
|
|
||||||
) {
|
|
||||||
const qrSize = 100.0;
|
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
|
||||||
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pw.Page(
|
|
||||||
pageFormat: PdfPageFormat.a4,
|
|
||||||
margin: const pw.EdgeInsets.all(20),
|
|
||||||
build: (_) => pw.Wrap(
|
build: (_) => pw.Wrap(
|
||||||
spacing: 10,
|
spacing: 0,
|
||||||
runSpacing: 10,
|
runSpacing: 0,
|
||||||
children: List.generate(pageItems.length, (i) {
|
children: List.generate(pageItems.length, (i) {
|
||||||
final item = pageItems[i];
|
|
||||||
return pw.Container(
|
return pw.Container(
|
||||||
width: 260,
|
width: labelWidth,
|
||||||
height: 120,
|
height: labelHeight,
|
||||||
decoration: pw.BoxDecoration(
|
padding: const pw.EdgeInsets.all(2),
|
||||||
border: pw.Border.all(color: PdfColors.grey400),
|
|
||||||
borderRadius: pw.BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
padding: const pw.EdgeInsets.all(8),
|
|
||||||
child: pw.Row(
|
child: pw.Row(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// QR Code
|
// QR Code à gauche
|
||||||
pw.Container(
|
pw.Container(
|
||||||
width: qrSize,
|
width: labelHeight - 4,
|
||||||
height: qrSize,
|
height: labelHeight - 4,
|
||||||
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
||||||
),
|
),
|
||||||
pw.SizedBox(width: 8),
|
pw.SizedBox(width: 4),
|
||||||
// Détails
|
// Texte à droite
|
||||||
pw.Expanded(
|
pw.Expanded(
|
||||||
child: pw.Column(
|
child: pw.Column(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Logo
|
// Logo
|
||||||
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
||||||
pw.Center(
|
pw.Container(
|
||||||
child: pw.Container(
|
height: 12,
|
||||||
height: 25,
|
alignment: pw.Alignment.centerLeft,
|
||||||
margin: const pw.EdgeInsets.only(bottom: 6),
|
margin: const pw.EdgeInsets.only(bottom: 2),
|
||||||
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Titre
|
|
||||||
if (getTitle != null) ...[
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
|
||||||
_truncate(getTitle(item), 20),
|
|
||||||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// ID
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
pw.Text(
|
||||||
getId(item),
|
getId(pageItems[i]),
|
||||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
// Détails supplémentaires
|
if (getTitle != null)
|
||||||
if (getDetails != null) ...[
|
pw.Text(
|
||||||
pw.SizedBox(height: 4),
|
_truncate(getTitle(pageItems[i]), 18),
|
||||||
...getDetails(item).take(5).map((line) {
|
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey700),
|
||||||
return pw.Padding(
|
maxLines: 2,
|
||||||
padding: const pw.EdgeInsets.only(bottom: 1),
|
overflow: pw.TextOverflow.clip,
|
||||||
child: pw.Text(
|
),
|
||||||
_truncate(line, 25),
|
|
||||||
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey800),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,10 +258,130 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// GRANDS LABELS (105 x 57 mm | 2 colonnes, 5 lignes)
|
||||||
|
// ========================================================================
|
||||||
|
static void _addLargeLabels<T>(
|
||||||
|
pw.Document pdf,
|
||||||
|
List<T> items,
|
||||||
|
String Function(T) getId,
|
||||||
|
String Function(T)? getTitle,
|
||||||
|
List<String> Function(T)? getDetails,
|
||||||
|
List<Uint8List> qrImages,
|
||||||
|
PDFGeneratorConfig config,
|
||||||
|
) {
|
||||||
|
// UTILISATION DE LA LARGEUR A4 DIVISÉE PAR 2
|
||||||
|
// Cela garantit que 2 colonnes rentrent pile poil (210mm / 2 = 105mm)
|
||||||
|
final double labelWidth = PdfPageFormat.a4.width / 2;
|
||||||
|
const double labelHeight = 57.0 * PdfPageFormat.mm;
|
||||||
|
const int cols = 2;
|
||||||
|
const int rows = 5;
|
||||||
|
|
||||||
|
final double totalGridWidth = labelWidth * cols;
|
||||||
|
final double totalGridHeight = labelHeight * rows;
|
||||||
|
|
||||||
|
const double innerQrSize = 45.0 * PdfPageFormat.mm;
|
||||||
|
|
||||||
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
|
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
|
|
||||||
|
pdf.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
build: (_) => pw.Center(
|
||||||
|
child: pw.Container(
|
||||||
|
width: totalGridWidth,
|
||||||
|
height: totalGridHeight,
|
||||||
|
child: pw.Wrap(
|
||||||
|
spacing: 0, // Très important : 0 espace entre les colonnes
|
||||||
|
runSpacing: 0, // 0 espace entre les lignes
|
||||||
|
children: List.generate(pageItems.length, (i) {
|
||||||
|
final item = pageItems[i];
|
||||||
|
// Déterminer si c'est la première colonne (indices pairs)
|
||||||
|
final bool isFirstColumn = (i % 2) == 0;
|
||||||
|
// Décalage de 2mm pour la première colonne
|
||||||
|
final double leftPadding = isFirstColumn ? 8.0 : 6.0; // 6 + 2mm
|
||||||
|
|
||||||
|
return pw.Container(
|
||||||
|
width: labelWidth,
|
||||||
|
height: labelHeight,
|
||||||
|
padding: pw.EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
right: 6,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
// Suppression de la décoration (bordure)
|
||||||
|
child: pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// QR Code
|
||||||
|
pw.Container(
|
||||||
|
width: innerQrSize,
|
||||||
|
height: innerQrSize,
|
||||||
|
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
||||||
|
),
|
||||||
|
pw.SizedBox(width: 8),
|
||||||
|
// Détails
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
||||||
|
pw.Container(
|
||||||
|
height: 20,
|
||||||
|
alignment: pw.Alignment.centerLeft,
|
||||||
|
margin: const pw.EdgeInsets.only(bottom: 4),
|
||||||
|
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
||||||
|
),
|
||||||
|
// Titre
|
||||||
|
if (getTitle != null) ...[
|
||||||
|
pw.Text(
|
||||||
|
_truncate(getTitle(item), 40),
|
||||||
|
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// ID
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
getId(item),
|
||||||
|
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
|
||||||
|
),
|
||||||
|
// Détails supplémentaires
|
||||||
|
if (getDetails != null) ...[
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
...getDetails(item).take(4).map((line) {
|
||||||
|
return pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.only(bottom: 1),
|
||||||
|
child: pw.Text(
|
||||||
|
_truncate(line, 35),
|
||||||
|
style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey800),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Nettoie le cache (logo)
|
/// Nettoie le cache (logo)
|
||||||
static void clearCache() {
|
static void clearCache() {
|
||||||
_cachedLogoBytes = null;
|
_cachedLogoBytes = null;
|
||||||
_logoLoadAttempted = false;
|
_logoLoadAttempted = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
240
em2rp/lib/services/qr_code_processing_service.dart
Normal file
240
em2rp/lib/services/qr_code_processing_service.dart
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/qr_code_process_result.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service pour traiter les codes QR scannés ou saisis manuellement
|
||||||
|
/// pendant la préparation d'un événement
|
||||||
|
class QRCodeProcessingService {
|
||||||
|
/// Traiter un code (équipement ou container)
|
||||||
|
Future<QRCodeProcessResult> processCode({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step, // Changed to dynamic to accept any PreparationStep enum
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, ContainerModel> containerCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Processing code: $code');
|
||||||
|
|
||||||
|
// Identifier le type selon le préfixe
|
||||||
|
final isContainer = code.startsWith('BOX_');
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
return await _processContainer(
|
||||||
|
code: code,
|
||||||
|
event: event,
|
||||||
|
step: step,
|
||||||
|
equipmentCache: equipmentCache,
|
||||||
|
containerCache: containerCache,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return await _processEquipment(
|
||||||
|
code: code,
|
||||||
|
event: event,
|
||||||
|
step: step,
|
||||||
|
equipmentCache: equipmentCache,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[QRCodeProcessingService] Error processing code', e);
|
||||||
|
return QRCodeProcessResult.error('Erreur lors du traitement du code: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un code d'équipement
|
||||||
|
Future<QRCodeProcessResult> _processEquipment({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
// Chercher l'équipement dans les équipements assignés
|
||||||
|
final eventEquipment = event.assignedEquipment
|
||||||
|
.cast<EventEquipment?>()
|
||||||
|
.firstWhere(
|
||||||
|
(eq) => eq?.equipmentId == code,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventEquipment == null) {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Equipment $code not found in event');
|
||||||
|
return QRCodeProcessResult.notFoundInEvent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
final equipment = equipmentCache[code];
|
||||||
|
final equipmentName = equipment?.name ?? 'Équipement inconnu';
|
||||||
|
|
||||||
|
// Vérifier si l'équipement a des quantités
|
||||||
|
if (equipment?.hasQuantity ?? false) {
|
||||||
|
return _processQuantitativeEquipment(
|
||||||
|
code: code,
|
||||||
|
equipmentName: equipmentName,
|
||||||
|
eventEquipment: eventEquipment,
|
||||||
|
step: step,
|
||||||
|
validationState: validationState,
|
||||||
|
currentQuantities: currentQuantities,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _processNonQuantitativeEquipment(
|
||||||
|
code: code,
|
||||||
|
equipmentName: equipmentName,
|
||||||
|
validationState: validationState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un équipement quantitatif (incrémenter la quantité)
|
||||||
|
QRCodeProcessResult _processQuantitativeEquipment({
|
||||||
|
required String code,
|
||||||
|
required String equipmentName,
|
||||||
|
required EventEquipment eventEquipment,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) {
|
||||||
|
final currentQty = currentQuantities[code] ?? 0;
|
||||||
|
final targetQty = _getTargetQuantity(eventEquipment, step);
|
||||||
|
|
||||||
|
// Vérifier si on a déjà atteint la quantité cible
|
||||||
|
if (currentQty >= targetQty) {
|
||||||
|
return QRCodeProcessResult.error(
|
||||||
|
'Quantité cible déjà atteinte pour $equipmentName ($currentQty/$targetQty)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrémenter la quantité
|
||||||
|
final newQty = currentQty + 1;
|
||||||
|
final shouldCheck = newQty >= targetQty;
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: '$equipmentName : $newQty/$targetQty${shouldCheck ? " ✓" : ""}',
|
||||||
|
affectedEquipmentIds: [code],
|
||||||
|
updatedQuantities: {code: newQty},
|
||||||
|
updatedValidationState: shouldCheck ? {code: true} : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un équipement non quantitatif (cocher)
|
||||||
|
QRCodeProcessResult _processNonQuantitativeEquipment({
|
||||||
|
required String code,
|
||||||
|
required String equipmentName,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
}) {
|
||||||
|
// Vérifier si déjà coché
|
||||||
|
if (validationState[code] == true) {
|
||||||
|
return QRCodeProcessResult.error('$equipmentName est déjà coché');
|
||||||
|
}
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: '$equipmentName a été coché ✓',
|
||||||
|
affectedEquipmentIds: [code],
|
||||||
|
updatedValidationState: {code: true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traiter un code de container (cocher tous les enfants)
|
||||||
|
Future<QRCodeProcessResult> _processContainer({
|
||||||
|
required String code,
|
||||||
|
required EventModel event,
|
||||||
|
required dynamic step,
|
||||||
|
required Map<String, EquipmentModel> equipmentCache,
|
||||||
|
required Map<String, ContainerModel> containerCache,
|
||||||
|
required Map<String, bool> validationState,
|
||||||
|
required Map<String, int> currentQuantities,
|
||||||
|
}) async {
|
||||||
|
// Vérifier que le container est assigné à l'événement
|
||||||
|
if (!event.assignedContainers.contains(code)) {
|
||||||
|
DebugLog.info('[QRCodeProcessingService] Container $code not found in event');
|
||||||
|
return QRCodeProcessResult.notFoundInEvent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerCache[code];
|
||||||
|
if (container == null) {
|
||||||
|
return QRCodeProcessResult.error('Container introuvable dans le cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter tous les équipements enfants
|
||||||
|
final updatedValidation = <String, bool>{};
|
||||||
|
final updatedQuantities = <String, int>{};
|
||||||
|
int processedCount = 0;
|
||||||
|
|
||||||
|
for (final childId in container.equipmentIds) {
|
||||||
|
final childEventEq = event.assignedEquipment
|
||||||
|
.cast<EventEquipment?>()
|
||||||
|
.firstWhere(
|
||||||
|
(eq) => eq?.equipmentId == childId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (childEventEq == null) continue;
|
||||||
|
|
||||||
|
final childEquipment = equipmentCache[childId];
|
||||||
|
|
||||||
|
// Si quantitatif, mettre la quantité actuelle = quantité cible
|
||||||
|
if (childEquipment?.hasQuantity ?? false) {
|
||||||
|
final targetQty = _getTargetQuantity(childEventEq, step);
|
||||||
|
updatedQuantities[childId] = targetQty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cocher l'enfant
|
||||||
|
updatedValidation[childId] = true;
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedCount == 0) {
|
||||||
|
return QRCodeProcessResult.error(
|
||||||
|
'Aucun équipement trouvé dans le container ${container.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return QRCodeProcessResult.success(
|
||||||
|
message: 'Container ${container.name} : $processedCount équipement(s) validé(s) ✓',
|
||||||
|
affectedEquipmentIds: updatedValidation.keys.toList(),
|
||||||
|
updatedValidationState: updatedValidation,
|
||||||
|
updatedQuantities: updatedQuantities.isNotEmpty ? updatedQuantities : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir la quantité requise selon l'étape
|
||||||
|
/// Logique: chaque étape utilise la quantité actuelle de l'étape N-1
|
||||||
|
int _getTargetQuantity(EventEquipment eventEquipment, dynamic step) {
|
||||||
|
// Convertir l'enum en string pour comparer
|
||||||
|
final stepString = step.toString().split('.').last;
|
||||||
|
|
||||||
|
switch (stepString) {
|
||||||
|
case 'preparation':
|
||||||
|
// Étape 1 : Quantité définie à la création de l'événement
|
||||||
|
return eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'loadingOutbound':
|
||||||
|
// Étape 2 : Quantité validée à l'étape 1 (préparation)
|
||||||
|
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'unloadingReturn':
|
||||||
|
// Étape 3 : Quantité validée à l'étape 2 (chargement)
|
||||||
|
return eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
|
||||||
|
case 'return_':
|
||||||
|
// Étape 4 : Quantité validée à l'étape 3 (déchargement)
|
||||||
|
return eventEquipment.quantityAtUnloading ??
|
||||||
|
eventEquipment.quantityAtLoading ??
|
||||||
|
eventEquipment.quantityAtPreparation ??
|
||||||
|
eventEquipment.quantity;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return eventEquipment.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
em2rp/lib/services/text_to_speech_service.dart
Normal file
244
em2rp/lib/services/text_to_speech_service.dart
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service de synthèse vocale pour lire des textes à haute voix (Web)
|
||||||
|
class TextToSpeechService {
|
||||||
|
static bool _isInitialized = false;
|
||||||
|
static bool _voicesLoaded = false;
|
||||||
|
static List<web.SpeechSynthesisVoice> _cachedVoices = [];
|
||||||
|
|
||||||
|
/// Initialiser le service TTS
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
final synthesis = web.window.speechSynthesis;
|
||||||
|
|
||||||
|
// Essayer de charger les voix immédiatement
|
||||||
|
_cachedVoices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
if (_cachedVoices.isNotEmpty) {
|
||||||
|
_voicesLoaded = true;
|
||||||
|
DebugLog.info('[TextToSpeechService] Service initialized with ${_cachedVoices.length} voices');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sur certains navigateurs (Firefox notamment), les voix se chargent de manière asynchrone
|
||||||
|
DebugLog.info('[TextToSpeechService] Waiting for voices to load asynchronously...');
|
||||||
|
|
||||||
|
// Attendre l'événement voiceschanged (si supporté)
|
||||||
|
final voicesLoaded = await _waitForVoices(synthesis);
|
||||||
|
|
||||||
|
if (voicesLoaded) {
|
||||||
|
_cachedVoices = synthesis.getVoices().toDart;
|
||||||
|
_voicesLoaded = true;
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Voices loaded asynchronously: ${_cachedVoices.length}');
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ No voices found after initialization');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attendre le chargement des voix (avec timeout)
|
||||||
|
static Future<bool> _waitForVoices(web.SpeechSynthesis synthesis) async {
|
||||||
|
// Essayer plusieurs fois avec des délais croissants
|
||||||
|
for (int attempt = 0; attempt < 5; attempt++) {
|
||||||
|
await Future.delayed(Duration(milliseconds: 100 * (attempt + 1)));
|
||||||
|
|
||||||
|
final voices = synthesis.getVoices().toDart;
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Attempt ${attempt + 1}/5: No voices yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un texte à haute voix
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final synthesis = web.window.speechSynthesis;
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Speaking requested: "$text"');
|
||||||
|
|
||||||
|
// Arrêter toute lecture en cours
|
||||||
|
synthesis.cancel();
|
||||||
|
|
||||||
|
// Attendre un peu pour que le cancel soit effectif
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// Créer une nouvelle utterance
|
||||||
|
final utterance = web.SpeechSynthesisUtterance(text);
|
||||||
|
utterance.lang = 'fr-FR';
|
||||||
|
utterance.rate = 0.7;
|
||||||
|
utterance.pitch = 0.7;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
// Récupérer les voix (depuis le cache ou re-charger)
|
||||||
|
var voices = _cachedVoices;
|
||||||
|
|
||||||
|
// Si le cache est vide, essayer de recharger
|
||||||
|
if (voices.isEmpty) {
|
||||||
|
DebugLog.info('[TextToSpeechService] Cache empty, reloading voices...');
|
||||||
|
voices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
// Sur Firefox/Linux, les voix peuvent ne pas être disponibles immédiatement
|
||||||
|
if (voices.isEmpty && !_voicesLoaded) {
|
||||||
|
DebugLog.info('[TextToSpeechService] Waiting for voices with multiple attempts...');
|
||||||
|
|
||||||
|
// Essayer plusieurs fois avec des délais
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
await Future.delayed(Duration(milliseconds: 100 * (i + 1)));
|
||||||
|
voices = synthesis.getVoices().toDart;
|
||||||
|
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Voices loaded on attempt ${i + 1}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le cache
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
_cachedVoices = voices;
|
||||||
|
_voicesLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[TextToSpeechService] Available voices: ${voices.length}');
|
||||||
|
|
||||||
|
if (voices.isNotEmpty) {
|
||||||
|
web.SpeechSynthesisVoice? selectedVoice;
|
||||||
|
|
||||||
|
// Lister TOUTES les voix françaises pour debug
|
||||||
|
final frenchVoices = <web.SpeechSynthesisVoice>[];
|
||||||
|
for (final voice in voices) {
|
||||||
|
final lang = voice.lang.toLowerCase();
|
||||||
|
if (lang.startsWith('fr')) {
|
||||||
|
frenchVoices.add(voice);
|
||||||
|
DebugLog.info('[TextToSpeechService] French: ${voice.name} (${voice.lang}) ${voice.localService ? 'LOCAL' : 'REMOTE'}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frenchVoices.isEmpty) {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ NO French voices found!');
|
||||||
|
DebugLog.info('[TextToSpeechService] Available languages:');
|
||||||
|
for (final voice in voices.take(5)) {
|
||||||
|
DebugLog.info('[TextToSpeechService] - ${voice.name} (${voice.lang})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stratégie de sélection: préférer les voix LOCALES (plus fiables sur Linux)
|
||||||
|
for (final voice in frenchVoices) {
|
||||||
|
if (voice.localService) {
|
||||||
|
selectedVoice = voice;
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Selected LOCAL French voice: ${voice.name}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas de voix locale, chercher une voix masculine
|
||||||
|
if (selectedVoice == null) {
|
||||||
|
for (final voice in frenchVoices) {
|
||||||
|
final name = voice.name.toLowerCase();
|
||||||
|
if (name.contains('male') ||
|
||||||
|
name.contains('homme') ||
|
||||||
|
name.contains('thomas') ||
|
||||||
|
name.contains('paul') ||
|
||||||
|
name.contains('bernard')) {
|
||||||
|
selectedVoice = voice;
|
||||||
|
DebugLog.info('[TextToSpeechService] Selected male voice: ${voice.name}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: première voix française
|
||||||
|
selectedVoice ??= frenchVoices.isNotEmpty ? frenchVoices.first : null;
|
||||||
|
|
||||||
|
if (selectedVoice != null) {
|
||||||
|
utterance.voice = selectedVoice;
|
||||||
|
utterance.lang = selectedVoice.lang; // Utiliser la langue de la voix
|
||||||
|
DebugLog.info('[TextToSpeechService] Final voice: ${selectedVoice.name} (${selectedVoice.lang})');
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] No French voice, using default with lang=fr-FR');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DebugLog.warning('[TextToSpeechService] ⚠ NO voices available at all!');
|
||||||
|
DebugLog.warning('[TextToSpeechService] On Linux: install speech-dispatcher and espeak-ng');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter des événements pour le debug
|
||||||
|
utterance.onstart = (web.SpeechSynthesisEvent event) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Speech started');
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
utterance.onend = (web.SpeechSynthesisEvent event) {
|
||||||
|
DebugLog.info('[TextToSpeechService] ✓ Speech ended');
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
utterance.onerror = (web.SpeechSynthesisErrorEvent event) {
|
||||||
|
DebugLog.error('[TextToSpeechService] ✗ Speech error: ${event.error}');
|
||||||
|
|
||||||
|
// Messages spécifiques pour aider au diagnostic
|
||||||
|
if (event.error == 'synthesis-failed') {
|
||||||
|
DebugLog.error('[TextToSpeechService] ⚠ SYNTHESIS FAILED - Common on Linux');
|
||||||
|
DebugLog.error('[TextToSpeechService] Possible causes:');
|
||||||
|
DebugLog.error('[TextToSpeechService] 1. speech-dispatcher not installed/running');
|
||||||
|
DebugLog.error('[TextToSpeechService] 2. espeak or espeak-ng not installed');
|
||||||
|
DebugLog.error('[TextToSpeechService] 3. No TTS engine configured');
|
||||||
|
DebugLog.error('[TextToSpeechService] Fix: sudo apt-get install speech-dispatcher espeak-ng');
|
||||||
|
DebugLog.error('[TextToSpeechService] Then restart browser');
|
||||||
|
} else if (event.error == 'network') {
|
||||||
|
DebugLog.error('[TextToSpeechService] Network error - online voice unavailable');
|
||||||
|
} else if (event.error == 'audio-busy') {
|
||||||
|
DebugLog.error('[TextToSpeechService] Audio system is busy');
|
||||||
|
}
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
// Lire le texte
|
||||||
|
synthesis.speak(utterance);
|
||||||
|
DebugLog.info('[TextToSpeechService] Speech command sent');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter la lecture en cours
|
||||||
|
static Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
web.window.speechSynthesis.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si le service est en train de lire
|
||||||
|
static Future<bool> isSpeaking() async {
|
||||||
|
try {
|
||||||
|
return web.window.speechSynthesis.speaking;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
web.window.speechSynthesis.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
em2rp/lib/services/update_service.dart
Normal file
131
em2rp/lib/services/update_service.dart
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:em2rp/config/app_version.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// Service pour gérer les mises à jour de l'application
|
||||||
|
class UpdateService {
|
||||||
|
// URL de votre version.json déployé sur Firebase Hosting
|
||||||
|
static const String versionUrl = 'https://app.em2events.fr/version.json';
|
||||||
|
|
||||||
|
/// Vérifie si une mise à jour est disponible
|
||||||
|
static Future<UpdateInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
// Récupérer la version actuelle depuis AppVersion
|
||||||
|
final currentVersion = AppVersion.version;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Current version: $currentVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la version depuis le serveur (avec cache-busting)
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$versionUrl?t=$timestamp'),
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final serverVersion = data['version'] as String;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Server version: $serverVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparer les versions
|
||||||
|
if (_isNewerVersion(serverVersion, currentVersion)) {
|
||||||
|
return UpdateInfo(
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
newVersion: serverVersion,
|
||||||
|
updateUrl: data['updateUrl'] as String?,
|
||||||
|
releaseNotes: data['releaseNotes'] as String?,
|
||||||
|
forceUpdate: data['forceUpdate'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Error checking for update: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare deux versions sémantiques (x.y.z)
|
||||||
|
/// Retourne true si newVersion > currentVersion
|
||||||
|
static bool _isNewerVersion(String newVersion, String currentVersion) {
|
||||||
|
final newParts = newVersion.split('.').map(int.parse).toList();
|
||||||
|
final currentParts = currentVersion.split('.').map(int.parse).toList();
|
||||||
|
|
||||||
|
// Comparer major
|
||||||
|
if (newParts[0] > currentParts[0]) return true;
|
||||||
|
if (newParts[0] < currentParts[0]) return false;
|
||||||
|
|
||||||
|
// Comparer minor
|
||||||
|
if (newParts[1] > currentParts[1]) return true;
|
||||||
|
if (newParts[1] < currentParts[1]) return false;
|
||||||
|
|
||||||
|
// Comparer patch
|
||||||
|
return newParts[2] > currentParts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force le rechargement de l'application (vide le cache)
|
||||||
|
static Future<void> reloadApp() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Pour le web, recharger la page en vidant le cache
|
||||||
|
// Utiliser window.location.reload(true) force un rechargement depuis le serveur
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Reloading app...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// On utilise launchUrl avec le mode _self pour recharger dans la même fenêtre
|
||||||
|
// Le paramètre de cache-busting garantit un nouveau chargement
|
||||||
|
final url = Uri.base;
|
||||||
|
final reloadUrl = url.replace(
|
||||||
|
queryParameters: {
|
||||||
|
...url.queryParameters,
|
||||||
|
'_reload': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await launchUrl(reloadUrl, webOnlyWindowName: '_self');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérification automatique au démarrage
|
||||||
|
static Future<UpdateInfo?> checkOnStartup() async {
|
||||||
|
// Attendre un peu avant de vérifier (pour ne pas ralentir le démarrage)
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
return await checkForUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informations sur une mise à jour disponible
|
||||||
|
class UpdateInfo {
|
||||||
|
final String currentVersion;
|
||||||
|
final String newVersion;
|
||||||
|
final String? updateUrl;
|
||||||
|
final String? releaseNotes;
|
||||||
|
final bool forceUpdate;
|
||||||
|
|
||||||
|
UpdateInfo({
|
||||||
|
required this.currentVersion,
|
||||||
|
required this.newVersion,
|
||||||
|
this.updateUrl,
|
||||||
|
this.releaseNotes,
|
||||||
|
this.forceUpdate = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get versionDifference {
|
||||||
|
return 'Nouvelle version disponible';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,49 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// @deprecated Ce service est obsolète. Utilisez UsersProvider avec DataService à la place.
|
||||||
|
/// Ce service reste pour compatibilité mais toutes les opérations passent par l'API.
|
||||||
class UserService {
|
class UserService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
/// @deprecated Utilisez UsersProvider.fetchUsers() à la place
|
||||||
Future<List<UserModel>> fetchUsers() async {
|
Future<List<UserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await _firestore.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
return snapshot.docs
|
return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
|
||||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur: $e");
|
print("Erreur: $e");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @deprecated Utilisez DataService.updateUser() à la place
|
||||||
Future<void> updateUser(UserModel user) async {
|
Future<void> updateUser(UserModel user) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(user.uid).update(user.toMap());
|
await _dataService.updateUser(user.uid, user.toMap());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur mise à jour: $e");
|
print("Erreur mise à jour: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @deprecated Utilisez API deleteUser à la place
|
||||||
Future<void> deleteUser(String uid) async {
|
Future<void> deleteUser(String uid) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(uid).delete();
|
final apiService = FirebaseFunctionsApiService();
|
||||||
|
await apiService.call('deleteUser', {'userId': uid});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur suppression: $e");
|
print("Erreur suppression: $e");
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Firebase Auth reste OK (pas Firestore)
|
||||||
Future<void> resetPassword(String email) async {
|
Future<void> resetPassword(String email) async {
|
||||||
try {
|
try {
|
||||||
|
// Firebase Auth est OK, ce n'est pas Firestore
|
||||||
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
||||||
print("Email de réinitialisation envoyé à $email");
|
print("Email de réinitialisation envoyé à $email");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
274
em2rp/lib/utils/app_permissions.dart
Normal file
274
em2rp/lib/utils/app_permissions.dart
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/// Énumération centralisée de toutes les permissions de l'application
|
||||||
|
/// Chaque permission contrôle l'accès à une fonctionnalité spécifique
|
||||||
|
enum AppPermission {
|
||||||
|
// ============= ÉVÉNEMENTS =============
|
||||||
|
/// Permet de voir les événements
|
||||||
|
viewEvents('view_events'),
|
||||||
|
|
||||||
|
/// Permet de créer de nouveaux événements
|
||||||
|
createEvents('create_events'),
|
||||||
|
|
||||||
|
/// Permet de modifier les événements existants
|
||||||
|
editEvents('edit_events'),
|
||||||
|
|
||||||
|
/// Permet de supprimer des événements
|
||||||
|
deleteEvents('delete_events'),
|
||||||
|
|
||||||
|
/// Permet de voir tous les événements de tous les utilisateurs
|
||||||
|
/// (nécessaire pour le filtre par utilisateur dans le calendrier)
|
||||||
|
viewAllUserEvents('view_all_user_events'),
|
||||||
|
|
||||||
|
// ============= ÉQUIPEMENTS =============
|
||||||
|
/// Permet de voir la liste des équipements
|
||||||
|
viewEquipment('view_equipment'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des équipements
|
||||||
|
/// Inclut aussi la gestion des prix d'achat/location
|
||||||
|
manageEquipment('manage_equipment'),
|
||||||
|
|
||||||
|
// ============= CONTENEURS =============
|
||||||
|
/// Permet de voir les conteneurs
|
||||||
|
viewContainers('view_containers'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des conteneurs
|
||||||
|
manageContainers('manage_containers'),
|
||||||
|
|
||||||
|
// ============= MAINTENANCE =============
|
||||||
|
/// Permet de voir les maintenances
|
||||||
|
viewMaintenance('view_maintenances'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des maintenances
|
||||||
|
manageMaintenance('manage_maintenances'),
|
||||||
|
|
||||||
|
// ============= UTILISATEURS =============
|
||||||
|
/// Permet de voir la liste de tous les utilisateurs
|
||||||
|
viewAllUsers('view_all_users'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des utilisateurs
|
||||||
|
/// Inclut la gestion des rôles
|
||||||
|
manageUsers('manage_users'),
|
||||||
|
|
||||||
|
// ============= ALERTES =============
|
||||||
|
/// Reçoit les alertes de maintenance
|
||||||
|
receiveMaintenanceAlerts('receive_maintenance_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes d'événements (création, modification)
|
||||||
|
receiveEventAlerts('receive_event_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes de stock faible
|
||||||
|
receiveStockAlerts('receive_stock_alerts'),
|
||||||
|
|
||||||
|
// ============= NOTIFICATIONS =============
|
||||||
|
/// Peut recevoir des notifications par email
|
||||||
|
receiveEmailNotifications('receive_email_notifications'),
|
||||||
|
|
||||||
|
/// Peut recevoir des notifications push dans le navigateur
|
||||||
|
receivePushNotifications('receive_push_notifications'),
|
||||||
|
|
||||||
|
// ============= PRÉPARATION/CHARGEMENT =============
|
||||||
|
/// Permet d'accéder aux pages de préparation d'événements
|
||||||
|
accessPreparation('access_preparation'),
|
||||||
|
|
||||||
|
/// Permet de valider les étapes de préparation
|
||||||
|
validatePreparation('validate_preparation'),
|
||||||
|
|
||||||
|
// ============= EXPORTS/RAPPORTS =============
|
||||||
|
/// Permet d'exporter des données (ICS, PDF, etc.)
|
||||||
|
exportData('export_data'),
|
||||||
|
|
||||||
|
/// Permet de générer des rapports
|
||||||
|
generateReports('generate_reports');
|
||||||
|
|
||||||
|
/// L'identifiant de la permission tel qu'il est stocké dans Firestore
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const AppPermission(this.id);
|
||||||
|
|
||||||
|
/// Convertit une string en AppPermission
|
||||||
|
static AppPermission? fromString(String? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return AppPermission.values.firstWhere((p) => p.id == value);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une description lisible de la permission (pour l'UI admin)
|
||||||
|
String get description {
|
||||||
|
switch (this) {
|
||||||
|
// Événements
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
return 'Voir les événements';
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
return 'Créer des événements';
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
return 'Modifier des événements';
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
return 'Supprimer des événements';
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Voir les événements de tous les utilisateurs';
|
||||||
|
|
||||||
|
// Équipements
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
return 'Voir les équipements';
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Gérer les équipements';
|
||||||
|
|
||||||
|
// Conteneurs
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
return 'Voir les conteneurs';
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Gérer les conteneurs';
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
return 'Voir les maintenances';
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Gérer les maintenances';
|
||||||
|
|
||||||
|
// Utilisateurs
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
return 'Voir tous les utilisateurs';
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Gérer les utilisateurs';
|
||||||
|
|
||||||
|
// Alertes
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
return 'Recevoir les alertes de maintenance';
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
return 'Recevoir les alertes d\'événements';
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Recevoir les alertes de stock';
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
return 'Recevoir les notifications par email';
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Recevoir les notifications push';
|
||||||
|
|
||||||
|
// Préparation
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
return 'Accéder aux préparations d\'événements';
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Valider les préparations';
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
case AppPermission.exportData:
|
||||||
|
return 'Exporter des données';
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Générer des rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la catégorie de la permission (pour l'UI de gestion des rôles)
|
||||||
|
String get category {
|
||||||
|
switch (this) {
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Événements';
|
||||||
|
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Équipements';
|
||||||
|
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Conteneurs';
|
||||||
|
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Maintenance';
|
||||||
|
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Utilisateurs';
|
||||||
|
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Alertes';
|
||||||
|
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Notifications';
|
||||||
|
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Préparation';
|
||||||
|
|
||||||
|
case AppPermission.exportData:
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Exports & Rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension pour faciliter les vérifications de permissions
|
||||||
|
extension PermissionListExtension on List<String> {
|
||||||
|
/// Vérifie si la liste contient une permission donnée
|
||||||
|
bool hasPermission(AppPermission permission) {
|
||||||
|
return contains(permission.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<AppPermission> permissions) {
|
||||||
|
return permissions.every((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<AppPermission> permissions) {
|
||||||
|
return permissions.any((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rôles prédéfinis avec leurs permissions
|
||||||
|
class PredefinedRoles {
|
||||||
|
/// Rôle ADMIN : Accès complet à toutes les fonctionnalités
|
||||||
|
static List<String> get admin => AppPermission.values.map((p) => p.id).toList();
|
||||||
|
|
||||||
|
/// Rôle TECHNICIEN : Gestion des équipements et préparation
|
||||||
|
static List<String> get technician => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.manageEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.manageContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.manageMaintenance.id,
|
||||||
|
AppPermission.receiveMaintenanceAlerts.id,
|
||||||
|
AppPermission.receiveStockAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.validatePreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle MANAGER : Gestion des événements et vue d'ensemble
|
||||||
|
static List<String> get manager => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.createEvents.id,
|
||||||
|
AppPermission.editEvents.id,
|
||||||
|
AppPermission.deleteEvents.id,
|
||||||
|
AppPermission.viewAllUserEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.viewAllUsers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
AppPermission.generateReports.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle USER : Consultation uniquement
|
||||||
|
static List<String> get user => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,14 +17,19 @@ class AuthGuard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
||||||
|
|
||||||
|
// Log pour débug
|
||||||
|
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
||||||
|
|
||||||
// Si l'utilisateur n'est pas connecté
|
// Si l'utilisateur n'est pas connecté
|
||||||
if (localAuthProvider.currentUser == null) {
|
if (localAuthProvider.currentUser == null) {
|
||||||
|
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
|
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
|
||||||
if (requiredPermission != null &&
|
if (requiredPermission != null &&
|
||||||
!localAuthProvider.hasPermission(requiredPermission!)) {
|
!localAuthProvider.hasPermission(requiredPermission!)) {
|
||||||
|
print('[AuthGuard] Permission "$requiredPermission" refusée');
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Accès refusé")),
|
appBar: AppBar(title: const Text("Accès refusé")),
|
||||||
body: const Center(
|
body: const Center(
|
||||||
@@ -34,6 +39,7 @@ class AuthGuard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, afficher la page demandée
|
// Sinon, afficher la page demandée
|
||||||
|
print('[AuthGuard] Accès autorisé, affichage de la page');
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ class CalendarUtils {
|
|||||||
|
|
||||||
static List<EventModel> getEventsForDay(
|
static List<EventModel> getEventsForDay(
|
||||||
DateTime day, List<EventModel> events) {
|
DateTime day, List<EventModel> events) {
|
||||||
final dayStart = DateTime(day.year, day.month, day.day, 0, 0);
|
final nextDay = day.add(const Duration(days: 1));
|
||||||
final dayEnd = DateTime(day.year, day.month, day.day, 23, 59, 59);
|
final dayStart = DateTime(day.year, day.month, day.day, 2, 0);
|
||||||
|
final dayEnd = DateTime(nextDay.year, nextDay.month, nextDay.day, 2, 59, 59);
|
||||||
|
|
||||||
return events.where((event) {
|
return events.where((event) {
|
||||||
return !(event.endDateTime.isBefore(dayStart) ||
|
return !(event.endDateTime.isBefore(dayStart) ||
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ class AppColors {
|
|||||||
static const Color blanc = Color(0xFFFFFFFF); // Blanc
|
static const Color blanc = Color(0xFFFFFFFF); // Blanc
|
||||||
static const Color rouge = Color.fromARGB(255, 159, 0, 0); // Rouge
|
static const Color rouge = Color.fromARGB(255, 159, 0, 0); // Rouge
|
||||||
static const Color gris = Color(0xFF808080); // Gris (gris moyen)
|
static const Color gris = Color(0xFF808080); // Gris (gris moyen)
|
||||||
|
static const Color bleuFonce = Color(0xFF1565C0); // Bleu foncé
|
||||||
}
|
}
|
||||||
|
|||||||
33
em2rp/lib/utils/debug_log.dart
Normal file
33
em2rp/lib/utils/debug_log.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Helper pour gérer les logs de debug
|
||||||
|
/// Les logs sont automatiquement désactivés en mode release
|
||||||
|
class DebugLog {
|
||||||
|
/// Flag pour activer/désactiver les logs manuellement
|
||||||
|
static const bool _forceEnableLogs = false;
|
||||||
|
|
||||||
|
/// Vérifie si les logs doivent être affichés
|
||||||
|
static bool get _shouldLog => kDebugMode || _forceEnableLogs;
|
||||||
|
|
||||||
|
/// Log une information
|
||||||
|
static void info(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log une erreur (toujours affiché, même en production)
|
||||||
|
static void error(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
print('ERROR: $message');
|
||||||
|
if (error != null) print(' Error: $error');
|
||||||
|
if (stackTrace != null && kDebugMode) print(' StackTrace: $stackTrace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log un warning
|
||||||
|
static void warning(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print('WARNING: $message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Helpers pour la gestion et l'affichage des équipements
|
||||||
|
class EquipmentHelpers {
|
||||||
|
/// Détermine si un équipement devrait avoir une quantité par défaut
|
||||||
|
/// Retourne true pour câbles, consommables et structures
|
||||||
|
static bool shouldBeQuantifiableByDefault(EquipmentCategory category) {
|
||||||
|
return category == EquipmentCategory.cable ||
|
||||||
|
category == EquipmentCategory.consumable ||
|
||||||
|
category == EquipmentCategory.structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule la quantité disponible d'un équipement
|
||||||
|
/// Prend en compte la quantité totale et la quantité déjà assignée
|
||||||
|
static int calculateAvailableQuantity(
|
||||||
|
EquipmentModel equipment,
|
||||||
|
int assignedQuantity,
|
||||||
|
) {
|
||||||
|
if (!equipment.hasQuantity) return 0;
|
||||||
|
|
||||||
|
final total = equipment.availableQuantity ?? equipment.totalQuantity ?? 0;
|
||||||
|
return (total - assignedQuantity).clamp(0, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est en stock faible
|
||||||
|
/// (quantité disponible en dessous du seuil critique)
|
||||||
|
static bool isLowStock(EquipmentModel equipment) {
|
||||||
|
if (!equipment.hasQuantity) return false;
|
||||||
|
if (equipment.criticalThreshold == null) return false;
|
||||||
|
|
||||||
|
final available = equipment.availableQuantity ?? 0;
|
||||||
|
return available <= equipment.criticalThreshold!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import 'package:flutter/foundation.dart'; // pour kIsWeb
|
import 'package:flutter/foundation.dart'; // pour kIsWeb
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class FirebaseStorageManager {
|
class FirebaseStorageManager {
|
||||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
||||||
/// Pour le Web, on fixe l'extension .jpg.
|
/// Pour le Web, on fixe l'extension .jpg.
|
||||||
/// 1. Construit le chemin : "ProfilePictures/UID.jpg"
|
/// 1. Construit le chemin : "ProfilePictures/UID.jpg"
|
||||||
/// 2. Supprime l'ancienne photo (si elle existe).
|
/// 2. Supprime l'ancienne photo (si elle existe).
|
||||||
/// 3. Upload la nouvelle photo.
|
/// 3. Upload la nouvelle photo.
|
||||||
/// 4. Met à jour Firestore avec l'URL de la nouvelle image.
|
/// 4. Met à jour Firestore avec l'URL de la nouvelle image via l'API.
|
||||||
Future<String?> sendProfilePicture(
|
Future<String?> sendProfilePicture(
|
||||||
{required XFile imageFile, required String uid}) async {
|
{required XFile imageFile, required String uid}) async {
|
||||||
try {
|
try {
|
||||||
@@ -57,17 +58,14 @@ class FirebaseStorageManager {
|
|||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl");
|
"FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl");
|
||||||
|
|
||||||
// 5. Mettre à jour Firestore avec l'URL de la photo de profil
|
// 5. Mettre à jour via l'API (plus sécurisé)
|
||||||
try {
|
try {
|
||||||
await _firestore
|
await _dataService.updateUser(uid, {'profilePhotoUrl': downloadUrl});
|
||||||
.collection('users')
|
|
||||||
.doc(uid)
|
|
||||||
.update({'profilePhotoUrl': downloadUrl});
|
|
||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Firestore mis à jour pour l'utilisateur $uid.");
|
"FirebaseStorageManager: Profil mis à jour via API pour l'utilisateur $uid.");
|
||||||
} catch (firestoreError) {
|
} catch (apiError) {
|
||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Erreur Firestore pour l'utilisateur $uid: $firestoreError");
|
"FirebaseStorageManager: Erreur API pour l'utilisateur $uid: $apiError");
|
||||||
return downloadUrl; // On retourne l'URL même si la mise à jour échoue
|
return downloadUrl; // On retourne l'URL même si la mise à jour échoue
|
||||||
}
|
}
|
||||||
return downloadUrl;
|
return downloadUrl;
|
||||||
|
|||||||
129
em2rp/lib/utils/performance_monitor.dart
Normal file
129
em2rp/lib/utils/performance_monitor.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Service de monitoring des performances de l'application
|
||||||
|
/// Permet de mesurer les temps de chargement et d'identifier les goulots d'étranglement
|
||||||
|
class PerformanceMonitor {
|
||||||
|
static final Map<String, DateTime> _timings = {};
|
||||||
|
static final Map<String, Duration> _results = {};
|
||||||
|
static bool _enabled = kDebugMode; // Actif uniquement en mode debug par défaut
|
||||||
|
|
||||||
|
/// Active ou désactive le monitoring
|
||||||
|
static void setEnabled(bool enabled) {
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre le chronomètre pour une opération
|
||||||
|
static void start(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
_timings[key] = DateTime.now();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] START: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrête le chronomètre et affiche le résultat
|
||||||
|
static void end(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
if (_timings.containsKey(key)) {
|
||||||
|
final duration = DateTime.now().difference(_timings[key]!);
|
||||||
|
_results[key] = duration;
|
||||||
|
_timings.remove(key);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
final color = _getColorForDuration(duration);
|
||||||
|
print('[PerformanceMonitor] $color END: $key - ${duration.inMilliseconds}ms');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] ⚠️ No start time found for: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque un point dans le temps (pour mesurer des étapes)
|
||||||
|
static void mark(String key) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] 📍 MARK: $key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les résultats de toutes les mesures
|
||||||
|
static Map<String, Duration> getResults() {
|
||||||
|
return Map.unmodifiable(_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche un résumé des performances
|
||||||
|
static void printSummary() {
|
||||||
|
if (!_enabled || _results.isEmpty) return;
|
||||||
|
|
||||||
|
print('\n' + '=' * 60);
|
||||||
|
print('PERFORMANCE SUMMARY');
|
||||||
|
print('=' * 60);
|
||||||
|
|
||||||
|
// Trier par durée décroissante
|
||||||
|
final sortedResults = _results.entries.toList()
|
||||||
|
..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
|
||||||
|
for (var entry in sortedResults) {
|
||||||
|
final color = _getColorForDuration(entry.value);
|
||||||
|
final ms = entry.value.inMilliseconds;
|
||||||
|
print('$color ${entry.key.padRight(40)} : ${ms.toString().padLeft(6)}ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = _results.values.fold<Duration>(
|
||||||
|
Duration.zero,
|
||||||
|
(sum, duration) => sum + duration,
|
||||||
|
);
|
||||||
|
print('${'=' * 60}');
|
||||||
|
print('TOTAL: ${total.inMilliseconds}ms');
|
||||||
|
print('=' * 60 + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialise toutes les mesures
|
||||||
|
static void reset() {
|
||||||
|
_timings.clear();
|
||||||
|
_results.clear();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[PerformanceMonitor] 🔄 Reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une couleur basée sur la durée (pour les logs)
|
||||||
|
static String _getColorForDuration(Duration duration) {
|
||||||
|
final ms = duration.inMilliseconds;
|
||||||
|
if (ms < 100) return '🟢'; // Rapide
|
||||||
|
if (ms < 500) return '🟡'; // Moyen
|
||||||
|
if (ms < 1000) return '🟠'; // Lent
|
||||||
|
return '🔴'; // Très lent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesure une opération asynchrone
|
||||||
|
static Future<T> measure<T>(String key, Future<T> Function() operation) async {
|
||||||
|
start(key);
|
||||||
|
try {
|
||||||
|
final result = await operation();
|
||||||
|
end(key);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
end(key);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesure une opération synchrone
|
||||||
|
static T measureSync<T>(String key, T Function() operation) {
|
||||||
|
start(key);
|
||||||
|
try {
|
||||||
|
final result = operation();
|
||||||
|
end(key);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
end(key);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
86
em2rp/lib/utils/price_helpers.dart
Normal file
86
em2rp/lib/utils/price_helpers.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
|
||||||
|
/// Helper pour la gestion des prix HT et TTC
|
||||||
|
class PriceHelpers {
|
||||||
|
/// Taux de TVA par défaut (20%)
|
||||||
|
static const double defaultTaxRate = 0.20;
|
||||||
|
|
||||||
|
/// Calcule le prix TTC à partir du prix HT
|
||||||
|
static double calculateTTC(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le prix HT à partir du prix TTC
|
||||||
|
static double calculateHT(double priceTTC, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceTTC / (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le montant de TVA
|
||||||
|
static double calculateTax(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * taxRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate un prix en euros avec deux décimales
|
||||||
|
static String formatPrice(double price) {
|
||||||
|
return '${price.toStringAsFixed(2)} €';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un objet EventPricing avec HT, TVA et TTC calculés
|
||||||
|
static EventPricing getPricing(EventModel event, {double taxRate = defaultTaxRate}) {
|
||||||
|
// basePrice dans Firestore est le prix TTC (avec TVA 20% déjà incluse)
|
||||||
|
final priceTTC = event.basePrice;
|
||||||
|
final priceHT = calculateHT(priceTTC, taxRate: taxRate);
|
||||||
|
final taxAmount = calculateTax(priceHT, taxRate: taxRate);
|
||||||
|
|
||||||
|
return EventPricing(
|
||||||
|
priceHT: priceHT,
|
||||||
|
taxAmount: taxAmount,
|
||||||
|
priceTTC: priceTTC,
|
||||||
|
taxRate: taxRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classe pour stocker les différentes composantes du prix d'un événement
|
||||||
|
class EventPricing {
|
||||||
|
final double priceHT;
|
||||||
|
final double taxAmount;
|
||||||
|
final double priceTTC;
|
||||||
|
final double taxRate;
|
||||||
|
|
||||||
|
const EventPricing({
|
||||||
|
required this.priceHT,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.priceTTC,
|
||||||
|
required this.taxRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne le taux de TVA en pourcentage (ex: 20.0 pour 20%)
|
||||||
|
double get taxRatePercentage => taxRate * 100;
|
||||||
|
|
||||||
|
/// Formate le prix HT
|
||||||
|
String get formattedHT => PriceHelpers.formatPrice(priceHT);
|
||||||
|
|
||||||
|
/// Formate le montant de TVA
|
||||||
|
String get formattedTax => PriceHelpers.formatPrice(taxAmount);
|
||||||
|
|
||||||
|
/// Formate le prix TTC
|
||||||
|
String get formattedTTC => PriceHelpers.formatPrice(priceTTC);
|
||||||
|
|
||||||
|
/// Retourne un résumé complet du pricing
|
||||||
|
String get summary => 'HT: $formattedHT | TVA (${taxRatePercentage.toStringAsFixed(0)}%): $formattedTax | TTC: $formattedTTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget helper pour afficher les prix
|
||||||
|
class PriceDisplay {
|
||||||
|
/// Génère un Map avec les composantes de prix pour affichage
|
||||||
|
static Map<String, String> getPriceComponents(EventModel event) {
|
||||||
|
final pricing = PriceHelpers.getPricing(event);
|
||||||
|
return {
|
||||||
|
'HT': pricing.formattedHT,
|
||||||
|
'TVA': '${pricing.formattedTax} (${pricing.taxRatePercentage.toStringAsFixed(0)}%)',
|
||||||
|
'TTC': pricing.formattedTTC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
em2rp/lib/utils/web_download.dart
Normal file
7
em2rp/lib/utils/web_download.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Fichier d'export conditionnel pour le téléchargement web
|
||||||
|
/// Utilise l'implémentation web sur le web, et le stub sur les autres plateformes
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'web_download_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'web_download_web.dart';
|
||||||
|
|
||||||
6
em2rp/lib/utils/web_download_stub.dart
Normal file
6
em2rp/lib/utils/web_download_stub.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// Stub pour le téléchargement web
|
||||||
|
/// Utilisé sur les plateformes non-web (mobile, desktop)
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
throw UnsupportedError('Le téléchargement web n\'est pas supporté sur cette plateforme');
|
||||||
|
}
|
||||||
|
|
||||||
29
em2rp/lib/utils/web_download_web.dart
Normal file
29
em2rp/lib/utils/web_download_web.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Implémentation web du téléchargement de fichier
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
final bytes = Uint8List.fromList(utf8.encode(content));
|
||||||
|
|
||||||
|
// Créer un Blob avec les données
|
||||||
|
final blob = web.Blob(
|
||||||
|
[bytes.toJS].toJS,
|
||||||
|
web.BlobPropertyBag(type: 'text/csv;charset=utf-8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer une URL pour le blob
|
||||||
|
final url = web.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Créer un lien de téléchargement et le cliquer
|
||||||
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
|
||||||
|
// Nettoyer l'URL
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +28,14 @@ class LoginViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
||||||
// Appelle la méthode du provider qui gère la connexion Auth ET le chargement des données utilisateur
|
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
emailController.text,
|
emailController.text,
|
||||||
passwordController.text,
|
passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
||||||
|
await localAuthProvider.loadUserData();
|
||||||
|
|
||||||
// Vérifier si le contexte est toujours valide
|
// Vérifier si le contexte est toujours valide
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
||||||
|
|||||||
296
em2rp/lib/views/alerts_page.dart
Normal file
296
em2rp/lib/views/alerts_page.dart
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/alert_service.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/views/widgets/alert_item.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Page listant toutes les alertes de l'utilisateur
|
||||||
|
class AlertsPage extends StatefulWidget {
|
||||||
|
const AlertsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlertsPage> createState() => _AlertsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlertsPageState extends State<AlertsPage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final AlertService _alertService = AlertService();
|
||||||
|
AlertType? _filter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_filter = _getFilterForTab(_tabController.index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertType? _getFilterForTab(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return null; // Toutes
|
||||||
|
case 1:
|
||||||
|
return AlertType.eventCreated; // Événements (on filtrera manuellement)
|
||||||
|
case 2:
|
||||||
|
return AlertType.maintenanceDue; // Maintenance
|
||||||
|
case 3:
|
||||||
|
return AlertType.lost; // Équipement
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final localUserProvider = context.watch<LocalUserProvider>();
|
||||||
|
final userId = localUserProvider.currentUser?.uid;
|
||||||
|
|
||||||
|
if (userId == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
),
|
||||||
|
body: const Center(
|
||||||
|
child: Text('Veuillez vous connecter'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.done_all),
|
||||||
|
onPressed: () => _markAllAsRead(userId),
|
||||||
|
tooltip: 'Tout marquer comme lu',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.white70,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Toutes'),
|
||||||
|
Tab(text: 'Événements'),
|
||||||
|
Tab(text: 'Maintenance'),
|
||||||
|
Tab(text: 'Équipement'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: _buildAlertsList(userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlertsList(String userId) {
|
||||||
|
return StreamBuilder<List<AlertModel>>(
|
||||||
|
stream: _alertService.alertsStreamForUser(userId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
// Log détaillé de l'erreur
|
||||||
|
print('[AlertsPage] ERREUR Stream: ${snapshot.error}');
|
||||||
|
print('[AlertsPage] StackTrace: ${snapshot.stackTrace}');
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Erreur de chargement des alertes'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
snapshot.error.toString(),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => setState(() {}),
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allAlerts = snapshot.data ?? [];
|
||||||
|
|
||||||
|
// Filtrer selon l'onglet sélectionné
|
||||||
|
final filteredAlerts = _filterAlerts(allAlerts);
|
||||||
|
|
||||||
|
if (filteredAlerts.isEmpty) {
|
||||||
|
return _buildEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: filteredAlerts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final alert = filteredAlerts[index];
|
||||||
|
return AlertItem(
|
||||||
|
alert: alert,
|
||||||
|
onTap: () => _handleAlertTap(alert),
|
||||||
|
onMarkAsRead: () => _markAsRead(alert.id),
|
||||||
|
onDelete: () => _deleteAlert(alert.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AlertModel> _filterAlerts(List<AlertModel> alerts) {
|
||||||
|
if (_filter == null) {
|
||||||
|
return alerts; // Toutes
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_tabController.index) {
|
||||||
|
case 1: // Événements
|
||||||
|
return alerts.where((a) => a.isEventAlert).toList();
|
||||||
|
case 2: // Maintenance
|
||||||
|
return alerts.where((a) => a.isMaintenanceAlert).toList();
|
||||||
|
case 3: // Équipement
|
||||||
|
return alerts.where((a) => a.isEquipmentAlert).toList();
|
||||||
|
default:
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
String message;
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
|
switch (_tabController.index) {
|
||||||
|
case 1:
|
||||||
|
message = 'Aucune alerte d\'événement';
|
||||||
|
icon = Icons.event;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
message = 'Aucune alerte de maintenance';
|
||||||
|
icon = Icons.build;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
message = 'Aucune alerte d\'équipement';
|
||||||
|
icon = Icons.inventory_2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = 'Aucune notification';
|
||||||
|
icon = Icons.notifications_none;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAlertTap(AlertModel alert) async {
|
||||||
|
// Marquer comme lu si pas déjà lu
|
||||||
|
if (!alert.isRead) {
|
||||||
|
await _markAsRead(alert.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirection selon actionUrl (pour l'instant, juste rester sur la page)
|
||||||
|
// TODO: Implémenter navigation vers événement/équipement si besoin
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _alertService.markAsRead(alertId);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _alertService.deleteAlert(alertId);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Alerte supprimée'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAllAsRead(String userId) async {
|
||||||
|
try {
|
||||||
|
final alerts = await _alertService.getAlertsForUser(userId);
|
||||||
|
for (final alert in alerts.where((a) => !a.isRead)) {
|
||||||
|
await _alertService.markAsRead(alert.id);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Toutes les alertes ont été marquées comme lues'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
@@ -12,6 +13,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
|||||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||||
import 'package:em2rp/views/event_add_page.dart';
|
import 'package:em2rp/views/event_add_page.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
||||||
|
import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
class CalendarPage extends StatefulWidget {
|
class CalendarPage extends StatefulWidget {
|
||||||
@@ -28,68 +30,129 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
bool _calendarCollapsed = false;
|
bool _calendarCollapsed = false;
|
||||||
int _selectedEventIndex = 0;
|
int _selectedEventIndex = 0;
|
||||||
|
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initializeDateFormatting('fr_FR', null);
|
initializeDateFormatting('fr_FR', null);
|
||||||
Future.microtask(() => _loadEvents());
|
// Charger les événements du mois courant après le premier build
|
||||||
// Sélection automatique de l'événement le plus proche de maintenant
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
_loadCurrentMonthEvents();
|
||||||
final events = eventProvider.events;
|
|
||||||
if (events.isNotEmpty) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
|
|
||||||
final todayEvents = events
|
|
||||||
.where((e) =>
|
|
||||||
e.startDateTime.year == now.year &&
|
|
||||||
e.startDateTime.month == now.month &&
|
|
||||||
e.startDateTime.day == now.day)
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
EventModel? selected;
|
|
||||||
DateTime? selectedDay;
|
|
||||||
int selectedEventIndex = 0;
|
|
||||||
if (todayEvents.isNotEmpty) {
|
|
||||||
selected = todayEvents[0];
|
|
||||||
selectedDay = DateTime(now.year, now.month, now.day);
|
|
||||||
} else {
|
|
||||||
// Chercher le prochain événement à venir
|
|
||||||
final futureEvents = events
|
|
||||||
.where((e) => e.startDateTime.isAfter(now))
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
if (futureEvents.isNotEmpty) {
|
|
||||||
selected = futureEvents[0];
|
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
|
||||||
} else {
|
|
||||||
// Aucun événement à venir, prendre le plus proche dans le passé
|
|
||||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
||||||
selected = events.last;
|
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_selectedDay = selectedDay;
|
|
||||||
_focusedDay = selectedDay!;
|
|
||||||
_selectedEventIndex = 0;
|
|
||||||
_selectedEvent = selected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charge les événements du mois courant avec lazy loading
|
||||||
|
Future<void> _loadCurrentMonthEvents() async {
|
||||||
|
PerformanceMonitor.start('CalendarPage.loadCurrentMonthEvents');
|
||||||
|
|
||||||
|
final localAuthProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userId = localAuthProvider.uid;
|
||||||
|
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
print('[CalendarPage] Loading events for ${_focusedDay.year}-${_focusedDay.month}');
|
||||||
|
|
||||||
|
await eventProvider.loadMonthEvents(
|
||||||
|
userId,
|
||||||
|
_focusedDay.year,
|
||||||
|
_focusedDay.month,
|
||||||
|
canViewAllEvents: canViewAllEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Précharger les mois adjacents en arrière-plan
|
||||||
|
eventProvider.preloadAdjacentMonths(
|
||||||
|
userId,
|
||||||
|
_focusedDay.year,
|
||||||
|
_focusedDay.month,
|
||||||
|
canViewAllEvents: canViewAllEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||||
|
_selectDefaultEvent();
|
||||||
|
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformanceMonitor.end('CalendarPage.loadCurrentMonthEvents');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||||
|
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||||
|
Future<void> _loadEventsAsync() async {
|
||||||
|
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||||
|
await _loadEvents();
|
||||||
|
|
||||||
|
// Sélectionner l'événement approprié après le chargement
|
||||||
|
if (mounted) {
|
||||||
|
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||||
|
_selectDefaultEvent();
|
||||||
|
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||||
|
}
|
||||||
|
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne automatiquement l'événement le plus proche de maintenant
|
||||||
|
void _selectDefaultEvent() {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final events = eventProvider.events;
|
||||||
|
|
||||||
|
if (events.isEmpty) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Trouver les événements d'aujourd'hui
|
||||||
|
final todayEvents = events.where((e) {
|
||||||
|
final start = e.startDateTime;
|
||||||
|
return start.year == now.year &&
|
||||||
|
start.month == now.month &&
|
||||||
|
start.day == now.day;
|
||||||
|
}).toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
|
EventModel? selected;
|
||||||
|
DateTime? selectedDay;
|
||||||
|
|
||||||
|
if (todayEvents.isNotEmpty) {
|
||||||
|
selected = todayEvents[0];
|
||||||
|
selectedDay = DateTime(now.year, now.month, now.day);
|
||||||
|
} else {
|
||||||
|
// Chercher le prochain événement à venir
|
||||||
|
final futureEvents = events
|
||||||
|
.where((e) => e.startDateTime.isAfter(now))
|
||||||
|
.toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
|
if (futureEvents.isNotEmpty) {
|
||||||
|
selected = futureEvents[0];
|
||||||
|
final start = selected.startDateTime;
|
||||||
|
selectedDay = DateTime(start.year, start.month, start.day);
|
||||||
|
} else {
|
||||||
|
// Aucun événement à venir, prendre le plus récent
|
||||||
|
final sortedEvents = events.toList()
|
||||||
|
..sort((a, b) => b.startDateTime.compareTo(a.startDateTime));
|
||||||
|
selected = sortedEvents.first;
|
||||||
|
final start = selected.startDateTime;
|
||||||
|
selectedDay = DateTime(start.year, start.month, start.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDay = selectedDay;
|
||||||
|
_focusedDay = selectedDay!;
|
||||||
|
_selectedEventIndex = 0;
|
||||||
|
_selectedEvent = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEvents() async {
|
Future<void> _loadEvents() async {
|
||||||
final localAuthProvider =
|
final localAuthProvider =
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localAuthProvider.uid;
|
final userId = localAuthProvider.uid;
|
||||||
print('Permissions utilisateur: ${localAuthProvider.permissions}');
|
|
||||||
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||||
print('canViewAllEvents: $canViewAllEvents');
|
|
||||||
|
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
await eventProvider.loadUserEvents(userId,
|
await eventProvider.loadUserEvents(userId,
|
||||||
@@ -97,6 +160,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
|
||||||
|
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
|
||||||
|
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||||
|
if (_selectedUserId == null) {
|
||||||
|
return allEvents; // Pas de filtre, retourner tous les événements
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce
|
||||||
|
return allEvents.where((event) {
|
||||||
|
return event.workforce.any((worker) {
|
||||||
|
if (worker is String) {
|
||||||
|
return worker == _selectedUserId;
|
||||||
|
}
|
||||||
|
// Si c'est une DocumentReference, on ne peut pas facilement comparer
|
||||||
|
// On suppose que les données sont chargées correctement en String
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
void _changeWeek(int delta) {
|
void _changeWeek(int delta) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
||||||
@@ -107,9 +190,19 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||||
|
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
// Appliquer le filtre utilisateur si actif
|
||||||
|
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||||
|
|
||||||
|
// Debug logs
|
||||||
|
print('[CalendarPage.build] Total events: ${eventProvider.events.length}, Filtered: ${filteredEvents.length}');
|
||||||
|
if (eventProvider.events.isNotEmpty) {
|
||||||
|
print('[CalendarPage.build] First event: ${eventProvider.events.first.name} at ${eventProvider.events.first.startDateTime}');
|
||||||
|
}
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -123,8 +216,42 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
title: "Calendrier",
|
title: "Calendrier",
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: Column(
|
||||||
floatingActionButton: isAdmin
|
children: [
|
||||||
|
// Filtre utilisateur dans le corps de la page
|
||||||
|
if (canViewAllUserEvents && !isMobile)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_list, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Filtrer par utilisateur :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: UserFilterDropdown(
|
||||||
|
selectedUserId: _selectedUserId,
|
||||||
|
onUserSelected: (userId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedUserId = userId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Corps du calendrier
|
||||||
|
Expanded(
|
||||||
|
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: canCreateEvents
|
||||||
? FloatingActionButton(
|
? FloatingActionButton(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -143,14 +270,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopLayout() {
|
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Calendrier (65% de la largeur)
|
// Calendrier (65% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 65,
|
flex: 65,
|
||||||
child: _buildCalendar(),
|
child: _buildCalendar(filteredEvents),
|
||||||
),
|
),
|
||||||
// Détails de l'événement (35% de la largeur)
|
// Détails de l'événement (35% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -159,7 +285,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
? EventDetails(
|
? EventDetails(
|
||||||
event: _selectedEvent!,
|
event: _selectedEvent!,
|
||||||
selectedDate: _selectedDay,
|
selectedDate: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onSelectEvent: (event, date) {
|
onSelectEvent: (event, date) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedEvent = event;
|
_selectedEvent = event;
|
||||||
@@ -178,11 +304,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
final eventsForSelectedDay = _selectedDay == null
|
final eventsForSelectedDay = _selectedDay == null
|
||||||
? []
|
? []
|
||||||
: eventProvider.events
|
: filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == _selectedDay!.year &&
|
e.startDateTime.year == _selectedDay!.year &&
|
||||||
e.startDateTime.month == _selectedDay!.month &&
|
e.startDateTime.month == _selectedDay!.month &&
|
||||||
@@ -216,16 +341,20 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -251,25 +380,31 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
|
final newMonth = DateTime(
|
||||||
|
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = DateTime(
|
_focusedDay = newMonth;
|
||||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
|
final newMonth = DateTime(
|
||||||
|
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = DateTime(
|
_focusedDay = newMonth;
|
||||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: MobileCalendarView(
|
child: MobileCalendarView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (day) {
|
onDaySelected: (day) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == day.year &&
|
e.startDateTime.year == day.year &&
|
||||||
e.startDateTime.month == day.month &&
|
e.startDateTime.month == day.month &&
|
||||||
@@ -422,10 +557,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_left,
|
icon: const Icon(Icons.chevron_left,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -463,10 +600,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_right,
|
icon: const Icon(Icons.chevron_right,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -505,13 +644,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendar() {
|
Widget _buildCalendar(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
|
|
||||||
if (_calendarFormat == CalendarFormat.week) {
|
if (_calendarFormat == CalendarFormat.week) {
|
||||||
return WeekView(
|
return WeekView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onWeekChange: _changeWeek,
|
onWeekChange: _changeWeek,
|
||||||
onEventSelected: (event) {
|
onEventSelected: (event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -525,7 +662,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDaySelected: (selectedDay) {
|
onDaySelected: (selectedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == selectedDay.year &&
|
e.startDateTime.year == selectedDay.year &&
|
||||||
e.startDateTime.month == selectedDay.month &&
|
e.startDateTime.month == selectedDay.month &&
|
||||||
@@ -557,9 +694,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
calendarFormat: _calendarFormat,
|
calendarFormat: _calendarFormat,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (selectedDay, focusedDay) {
|
onDaySelected: (selectedDay, focusedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((event) =>
|
.where((event) =>
|
||||||
event.startDateTime.year == selectedDay.year &&
|
event.startDateTime.year == selectedDay.year &&
|
||||||
event.startDateTime.month == selectedDay.month &&
|
event.startDateTime.month == selectedDay.month &&
|
||||||
@@ -582,9 +719,19 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPageChanged: (focusedDay) {
|
onPageChanged: (focusedDay) {
|
||||||
|
// Détecter si on a changé de mois
|
||||||
|
final monthChanged = focusedDay.year != _focusedDay.year ||
|
||||||
|
focusedDay.month != _focusedDay.month;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = focusedDay;
|
_focusedDay = focusedDay;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Charger les événements du nouveau mois si nécessaire
|
||||||
|
if (monthChanged) {
|
||||||
|
print('[CalendarPage] Month changed to ${focusedDay.year}-${focusedDay.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onEventSelected: (event) {
|
onEventSelected: (event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -622,6 +622,10 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/models/container_model.dart';
|
|||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
@@ -534,7 +535,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +581,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +594,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors du retrait de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +912,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -932,6 +937,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return Icons.inventory;
|
return Icons.inventory;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.category;
|
return Icons.category;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import 'package:em2rp/utils/permission_gate.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
|
|
||||||
import 'package:em2rp/views/widgets/management/management_card.dart';
|
import 'package:em2rp/views/widgets/management/management_card.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
class ContainerManagementPage extends StatefulWidget {
|
class ContainerManagementPage extends StatefulWidget {
|
||||||
const ContainerManagementPage({super.key});
|
const ContainerManagementPage({super.key});
|
||||||
@@ -25,13 +29,61 @@ class ContainerManagementPage extends StatefulWidget {
|
|||||||
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||||
with SelectionModeMixin<ContainerManagementPage> {
|
with SelectionModeMixin<ContainerManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
EquipmentStatus? _selectedStatus;
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||||
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Activer le mode pagination
|
||||||
|
final provider = context.read<ContainerProvider>();
|
||||||
|
provider.enablePagination();
|
||||||
|
|
||||||
|
// Ajouter le listener de scroll
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Charger la première page
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
provider.loadFirstPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
// Éviter les appels multiples
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
|
final provider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
|
// Charger la page suivante quand on arrive à 300px du bas
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
|
||||||
|
// Vérifier qu'on peut charger plus
|
||||||
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
provider.loadNextPage().then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}).catchError((error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
context.read<ContainerProvider>().disablePagination();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +120,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
const NotificationBadge(),
|
||||||
if (hasSelection) ...[
|
if (hasSelection) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||||
@@ -82,44 +135,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: AppBar(
|
: CustomAppBar(
|
||||||
title: const Text('Gestion des Containers'),
|
title: 'Gestion des Containers',
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
tooltip: 'Retour à la gestion des équipements',
|
tooltip: 'Retour à la gestion des équipements',
|
||||||
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
|
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
|
||||||
),
|
),
|
||||||
actions: [
|
showLogoutButton: true,
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout, color: Colors.white),
|
|
||||||
onPressed: () async {
|
|
||||||
final shouldLogout = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Déconnexion'),
|
|
||||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text('Déconnexion'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (shouldLogout == true && context.mounted) {
|
|
||||||
await context.read<LocalUserProvider>().signOut();
|
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.pushReplacementNamed(context, '/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/container_management'),
|
drawer: const MainDrawer(currentPage: '/container_management'),
|
||||||
floatingActionButton: !isSelectionMode
|
floatingActionButton: !isSelectionMode
|
||||||
@@ -169,14 +192,37 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchBar() {
|
Widget _buildSearchBar() {
|
||||||
return ManagementSearchBar(
|
return SearchActionsBar(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
hintText: 'Rechercher un container...',
|
hintText: 'Rechercher un container...',
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<ContainerProvider>().setSearchQuery(value);
|
context.read<ContainerProvider>().setSearchQuery(value);
|
||||||
},
|
},
|
||||||
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode,
|
onClear: () {
|
||||||
showSelectionModeButton: !isSelectionMode,
|
_searchController.clear();
|
||||||
|
context.read<ContainerProvider>().setSearchQuery('');
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: _scanQRCode,
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
tooltip: 'Scanner un QR Code',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSelectionMode)
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: toggleSelectionMode,
|
||||||
|
icon: const Icon(Icons.checklist),
|
||||||
|
tooltip: 'Mode sélection',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,30 +307,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
...ContainerType.values.map((type) {
|
...ContainerType.values.map((type) {
|
||||||
return _buildFilterOption(type, type.label);
|
return _buildFilterOption(type, type.label);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
const Divider(height: 32),
|
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
Text(
|
|
||||||
'Statut',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.noir,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStatusFilter(null, 'Tous les statuts'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterOption(ContainerType? type, String label) {
|
Widget _buildFilterOption(ContainerType? type, String label) {
|
||||||
final isSelected = _selectedType == type;
|
|
||||||
return RadioListTile<ContainerType?>(
|
return RadioListTile<ContainerType?>(
|
||||||
title: Text(label),
|
title: Text(label),
|
||||||
value: type,
|
value: type,
|
||||||
@@ -301,36 +329,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
|
|
||||||
final isSelected = _selectedStatus == status;
|
|
||||||
return RadioListTile<EquipmentStatus?>(
|
|
||||||
title: Text(label),
|
|
||||||
value: status,
|
|
||||||
groupValue: _selectedStatus,
|
|
||||||
activeColor: AppColors.rouge,
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedStatus = value;
|
|
||||||
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContainerList() {
|
Widget _buildContainerList() {
|
||||||
return Consumer<ContainerProvider>(
|
return Consumer<ContainerProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
return ManagementList<ContainerModel>(
|
// Afficher l'indicateur de chargement initial
|
||||||
stream: provider.containersStream,
|
if (provider.isLoading && provider.containers.isEmpty) {
|
||||||
cachedItems: _cachedContainers,
|
return const Center(child: CircularProgressIndicator());
|
||||||
emptyMessage: 'Aucun container trouvé',
|
}
|
||||||
emptyIcon: Icons.inventory_2_outlined,
|
|
||||||
onDataReceived: (items) {
|
final containers = provider.containers;
|
||||||
_cachedContainers = items;
|
|
||||||
|
// Afficher le message vide
|
||||||
|
if (containers.isEmpty && !provider.isLoading) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun container trouvé',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le nombre total d'items
|
||||||
|
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// Dernier élément = indicateur de chargement
|
||||||
|
if (index == containers.length) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: provider.isLoadingMore
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildContainerCard(containers[index]);
|
||||||
},
|
},
|
||||||
itemBuilder: (container) => _buildContainerCard(container),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -417,7 +471,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
_editContainer(container);
|
_editContainer(container);
|
||||||
break;
|
break;
|
||||||
case 'qr':
|
case 'qr':
|
||||||
// Non utilisé - les QR codes multiples sont générés via _generateQRCodesForSelected
|
_showQRCode(container);
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
_deleteContainer(container);
|
_deleteContainer(container);
|
||||||
@@ -425,6 +479,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Afficher le QR code d'un conteneur
|
||||||
|
void _showQRCode(ContainerModel container) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeDialog.forContainer(container),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _navigateToForm(BuildContext context) async {
|
void _navigateToForm(BuildContext context) async {
|
||||||
final result = await Navigator.pushNamed(context, '/container_form');
|
final result = await Navigator.pushNamed(context, '/container_form');
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
@@ -452,49 +514,81 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
Future<void> _generateQRCodesForSelected() async {
|
Future<void> _generateQRCodesForSelected() async {
|
||||||
if (!hasSelection) return;
|
if (!hasSelection) return;
|
||||||
|
|
||||||
// Récupérer les containers sélectionnés
|
// Afficher un indicateur de chargement
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
showDialog(
|
||||||
final List<ContainerModel> selectedContainers = [];
|
context: context,
|
||||||
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
for (final id in selectedIds) {
|
try {
|
||||||
final container = await containerProvider.getContainerById(id);
|
// Récupérer les containers sélectionnés
|
||||||
if (container != null) {
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
selectedContainers.add(container);
|
final List<ContainerModel> selectedContainers = [];
|
||||||
// Charger les équipements pour ce container
|
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
|
||||||
final equipment = await containerProvider.getContainerEquipment(id);
|
|
||||||
containerEquipmentMap[id] = equipment;
|
for (final id in selectedIds) {
|
||||||
|
final container = await containerProvider.getContainerById(id);
|
||||||
|
if (container != null) {
|
||||||
|
selectedContainers.add(container);
|
||||||
|
// Charger les équipements pour ce container
|
||||||
|
final equipment = await containerProvider.getContainerEquipment(id);
|
||||||
|
containerEquipmentMap[id] = equipment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedContainers.isEmpty) {
|
// Fermer l'indicateur de chargement
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Navigator.of(context).pop();
|
||||||
const SnackBar(content: Text('Aucun container trouvé')),
|
}
|
||||||
|
|
||||||
|
if (selectedContainers.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun container trouvé')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher le dialogue de sélection de format avec le widget générique
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeFormatSelectorDialog<ContainerModel>(
|
||||||
|
itemList: selectedContainers,
|
||||||
|
getId: (c) => c.id,
|
||||||
|
getTitle: (c) => c.name,
|
||||||
|
getDetails: (ContainerModel c) {
|
||||||
|
final equipment = containerEquipmentMap[c.id] ?? <EquipmentModel>[];
|
||||||
|
return [
|
||||||
|
'Contenu (${equipment.length}):',
|
||||||
|
...equipment.take(5).map((eq) => '- ${eq.id}'),
|
||||||
|
if (equipment.length > 5) '... +${equipment.length - 5}',
|
||||||
|
];
|
||||||
|
},
|
||||||
|
dialogTitle: 'Générer ${selectedContainers.length} QR Code(s)',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
// Fermer l'indicateur si une erreur survient
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
// Afficher le dialogue de sélection de format avec le widget générique
|
DebugLog.error('[ContainerManagementPage] Error generating QR codes', e);
|
||||||
if (mounted) {
|
|
||||||
showDialog(
|
if (mounted) {
|
||||||
context: context,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
builder: (context) => QRCodeFormatSelectorDialog<ContainerModel>(
|
SnackBar(
|
||||||
itemList: selectedContainers,
|
content: Text('Erreur lors de la génération : ${e.toString()}'),
|
||||||
getId: (c) => c.id,
|
backgroundColor: Colors.red,
|
||||||
getTitle: (c) => c.name,
|
),
|
||||||
getDetails: (ContainerModel c) {
|
);
|
||||||
final equipment = containerEquipmentMap[c.id] ?? <EquipmentModel>[];
|
}
|
||||||
return [
|
|
||||||
'Contenu (${equipment.length}):',
|
|
||||||
...equipment.take(5).map((eq) => '- ${eq.id}'),
|
|
||||||
if (equipment.length > 5) '... +${equipment.length - 5}',
|
|
||||||
];
|
|
||||||
},
|
|
||||||
dialogTitle: 'Générer ${selectedContainers.length} QR Code(s)',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,5 +677,119 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scanner un QR Code et ouvrir la vue de détail correspondante
|
||||||
|
Future<void> _scanQRCode() async {
|
||||||
|
try {
|
||||||
|
// Ouvrir le scanner
|
||||||
|
final scannedCode = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const QRCodeScannerDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scannedCode == null || scannedCode.isEmpty) {
|
||||||
|
return; // L'utilisateur a annulé
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Afficher un indicateur de chargement
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechercher d'abord dans les conteneurs
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
if (containerProvider.containers.isEmpty) {
|
||||||
|
await containerProvider.loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerProvider.containers.firstWhere(
|
||||||
|
(c) => c.id == scannedCode,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(); // Fermer l'indicateur
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.id.isNotEmpty) {
|
||||||
|
// Conteneur trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
'/container_detail',
|
||||||
|
arguments: container,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas trouvé dans les conteneurs, chercher dans les équipements
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
|
||||||
|
final equipment = equipmentProvider.allEquipment.firstWhere(
|
||||||
|
(eq) => eq.id == scannedCode,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (equipment.id.isNotEmpty) {
|
||||||
|
// Équipement trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rien trouvé
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Aucun conteneur ou équipement trouvé avec l\'ID : $scannedCode'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerManagementPage] Error scanning QR code', e);
|
||||||
|
if (mounted) {
|
||||||
|
// Fermer l'indicateur si ouvert
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur lors du scan : ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
@@ -26,6 +27,11 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
widget: const OptionsManagement(),
|
widget: const OptionsManagement(),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Exporter les événements',
|
||||||
|
icon: Icons.file_download,
|
||||||
|
widget: const EventsExport(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ import 'package:em2rp/services/qr_code_service.dart';
|
|||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
|
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_notes_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_notes_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_associated_events_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_associated_events_section.dart';
|
||||||
|
import 'package:em2rp/views/widgets/equipment/equipment_current_events_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
|
||||||
|
import 'package:em2rp/views/maintenance_form_page.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
@@ -107,11 +108,15 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 4. Événements associés
|
// 4. Événements en cours
|
||||||
|
EquipmentCurrentEventsSection(equipment: widget.equipment),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 5. Événements passés / à venir
|
||||||
EquipmentAssociatedEventsSection(equipment: widget.equipment),
|
EquipmentAssociatedEventsSection(equipment: widget.equipment),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 5-7. Prix, Historique des maintenances, Dates en layout responsive
|
// 6-8. Prix, Historique des maintenances, Dates en layout responsive
|
||||||
if (isDesktop)
|
if (isDesktop)
|
||||||
_buildDesktopTwoColumnLayout(hasManagePermission)
|
_buildDesktopTwoColumnLayout(hasManagePermission)
|
||||||
else
|
else
|
||||||
@@ -119,15 +124,9 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Containers parents (si applicable)
|
// Containers contenant cet équipement
|
||||||
if (widget.equipment.parentBoxIds.isNotEmpty) ...[
|
// Note: On utilise EquipmentReferencingContainers qui recherche dynamiquement
|
||||||
EquipmentParentContainers(
|
// les containers au lieu de se baser sur parentBoxIds qui peut être désynchronisé
|
||||||
parentBoxIds: widget.equipment.parentBoxIds,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Containers associés
|
|
||||||
EquipmentReferencingContainers(
|
EquipmentReferencingContainers(
|
||||||
equipmentId: widget.equipment.id,
|
equipmentId: widget.equipment.id,
|
||||||
),
|
),
|
||||||
@@ -154,6 +153,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
maintenances: _maintenances,
|
maintenances: _maintenances,
|
||||||
isLoading: _isLoadingMaintenances,
|
isLoading: _isLoadingMaintenances,
|
||||||
hasManagePermission: hasManagePermission,
|
hasManagePermission: hasManagePermission,
|
||||||
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -177,6 +177,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
maintenances: _maintenances,
|
maintenances: _maintenances,
|
||||||
isLoading: _isLoadingMaintenances,
|
isLoading: _isLoadingMaintenances,
|
||||||
hasManagePermission: hasManagePermission,
|
hasManagePermission: hasManagePermission,
|
||||||
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
EquipmentDatesSection(equipment: widget.equipment),
|
EquipmentDatesSection(equipment: widget.equipment),
|
||||||
@@ -251,6 +252,17 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
||||||
style: TextStyle(color: Colors.grey[700]),
|
style: TextStyle(color: Colors.grey[700]),
|
||||||
),
|
),
|
||||||
|
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'📁 ${widget.equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 13,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -369,6 +381,36 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Planifier une nouvelle maintenance pour cet équipment
|
||||||
|
Future<void> _planMaintenance() async {
|
||||||
|
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final hasPermission = userProvider.hasPermission('manage_maintenances');
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MaintenanceFormPage(
|
||||||
|
initialEquipmentIds: [widget.equipment.id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recharger les maintenances si une maintenance a été créée
|
||||||
|
if (result == true && mounted) {
|
||||||
|
await _loadMaintenances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _editEquipment() {
|
void _editEquipment() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -395,26 +437,33 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
// Fermer le dialog
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// Capturer le ScaffoldMessenger avant la suppression
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context
|
await context
|
||||||
.read<EquipmentProvider>()
|
.read<EquipmentProvider>()
|
||||||
.deleteEquipment(widget.equipment.id);
|
.deleteEquipment(widget.equipment.id);
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
// Revenir à la page précédente
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
navigator.pop();
|
||||||
const SnackBar(
|
|
||||||
content: Text('Équipement supprimé avec succès'),
|
// Afficher le snackbar (même si le widget est démonté)
|
||||||
backgroundColor: Colors.green,
|
scaffoldMessenger.showSnackBar(
|
||||||
),
|
const SnackBar(
|
||||||
);
|
content: Text('Équipement supprimé avec succès'),
|
||||||
}
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
// Afficher l'erreur
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e')),
|
SnackBar(content: Text('Erreur: $e')),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
|||||||
66
em2rp/lib/views/equipment_form/subcategory_selector.dart
Normal file
66
em2rp/lib/views/equipment_form/subcategory_selector.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Widget de sélection de sous-catégorie avec autocomplétion
|
||||||
|
/// Similaire au système Brand/Model mais filtré par catégorie
|
||||||
|
class SubCategorySelector extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final EquipmentCategory? selectedCategory;
|
||||||
|
final List<String> filteredSubCategories;
|
||||||
|
final ValueChanged<String?>? onChanged;
|
||||||
|
|
||||||
|
const SubCategorySelector({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.selectedCategory,
|
||||||
|
required this.filteredSubCategories,
|
||||||
|
this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Autocomplete<String>(
|
||||||
|
initialValue: TextEditingValue(text: controller.text),
|
||||||
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||||
|
if (selectedCategory == null) {
|
||||||
|
return const Iterable<String>.empty();
|
||||||
|
}
|
||||||
|
if (textEditingValue.text.isEmpty) {
|
||||||
|
return filteredSubCategories;
|
||||||
|
}
|
||||||
|
return filteredSubCategories.where((String subCategory) {
|
||||||
|
return subCategory.toLowerCase().contains(
|
||||||
|
textEditingValue.text.toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSelected: (String selection) {
|
||||||
|
controller.text = selection;
|
||||||
|
onChanged?.call(selection);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, fieldController, focusNode, onEditingComplete) {
|
||||||
|
if (fieldController.text != controller.text) {
|
||||||
|
fieldController.text = controller.text;
|
||||||
|
}
|
||||||
|
return TextFormField(
|
||||||
|
controller: fieldController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
enabled: selectedCategory != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sous-catégorie',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(Icons.category_outlined),
|
||||||
|
hintText: selectedCategory == null
|
||||||
|
? 'Catégorie requise'
|
||||||
|
: 'Saisissez la sous-catégorie',
|
||||||
|
helperText: 'Optionnel - Permet un classement plus précis',
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.text = value;
|
||||||
|
onChanged?.call(value.isNotEmpty ? value : null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
||||||
|
import 'package:em2rp/views/equipment_form/subcategory_selector.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class EquipmentFormPage extends StatefulWidget {
|
class EquipmentFormPage extends StatefulWidget {
|
||||||
final EquipmentModel? equipment;
|
final EquipmentModel? equipment;
|
||||||
@@ -28,6 +30,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
final TextEditingController _identifierController = TextEditingController();
|
final TextEditingController _identifierController = TextEditingController();
|
||||||
final TextEditingController _brandController = TextEditingController();
|
final TextEditingController _brandController = TextEditingController();
|
||||||
final TextEditingController _modelController = TextEditingController();
|
final TextEditingController _modelController = TextEditingController();
|
||||||
|
final TextEditingController _subCategoryController = TextEditingController();
|
||||||
final TextEditingController _purchasePriceController = TextEditingController();
|
final TextEditingController _purchasePriceController = TextEditingController();
|
||||||
final TextEditingController _rentalPriceController = TextEditingController();
|
final TextEditingController _rentalPriceController = TextEditingController();
|
||||||
final TextEditingController _totalQuantityController = TextEditingController();
|
final TextEditingController _totalQuantityController = TextEditingController();
|
||||||
@@ -41,18 +44,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
DateTime? _purchaseDate;
|
DateTime? _purchaseDate;
|
||||||
DateTime? _lastMaintenanceDate;
|
DateTime? _lastMaintenanceDate;
|
||||||
DateTime? _nextMaintenanceDate;
|
DateTime? _nextMaintenanceDate;
|
||||||
List<String> _selectedParentBoxIds = [];
|
|
||||||
List<EquipmentModel> _availableBoxes = [];
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isLoadingBoxes = true;
|
|
||||||
bool _addMultiple = false;
|
bool _addMultiple = false;
|
||||||
String? _selectedBrand;
|
String? _selectedBrand;
|
||||||
List<String> _filteredModels = [];
|
List<String> _filteredModels = [];
|
||||||
|
List<String> _filteredSubCategories = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAvailableBoxes();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
provider.loadBrands();
|
provider.loadBrands();
|
||||||
@@ -65,45 +65,35 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
|
|
||||||
void _populateFields() {
|
void _populateFields() {
|
||||||
final equipment = widget.equipment!;
|
final equipment = widget.equipment!;
|
||||||
_identifierController.text = equipment.id;
|
setState(() {
|
||||||
_brandController.text = equipment.brand ?? '';
|
_identifierController.text = equipment.id;
|
||||||
_selectedBrand = equipment.brand;
|
_brandController.text = equipment.brand ?? '';
|
||||||
_modelController.text = equipment.model ?? '';
|
_selectedBrand = equipment.brand;
|
||||||
_selectedCategory = equipment.category;
|
_modelController.text = equipment.model ?? '';
|
||||||
_selectedStatus = equipment.status;
|
_subCategoryController.text = equipment.subCategory ?? '';
|
||||||
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
|
_selectedCategory = equipment.category;
|
||||||
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
|
_selectedStatus = equipment.status;
|
||||||
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
|
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
|
||||||
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
|
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
|
||||||
_purchaseDate = equipment.purchaseDate;
|
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
|
||||||
_lastMaintenanceDate = equipment.lastMaintenanceDate;
|
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
|
||||||
_nextMaintenanceDate = equipment.nextMaintenanceDate;
|
_purchaseDate = equipment.purchaseDate;
|
||||||
_selectedParentBoxIds = List.from(equipment.parentBoxIds);
|
_lastMaintenanceDate = equipment.lastMaintenanceDate;
|
||||||
_notesController.text = equipment.notes ?? '';
|
_nextMaintenanceDate = equipment.nextMaintenanceDate;
|
||||||
|
_notesController.text = equipment.notes ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
||||||
|
|
||||||
|
|
||||||
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
||||||
_loadFilteredModels(_selectedBrand!);
|
_loadFilteredModels(_selectedBrand!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charger les sous-catégories pour la catégorie sélectionnée
|
||||||
|
_loadFilteredSubCategories(_selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAvailableBoxes() async {
|
|
||||||
try {
|
|
||||||
final boxes = await _equipmentService.getBoxes();
|
|
||||||
setState(() {
|
|
||||||
_availableBoxes = boxes;
|
|
||||||
_isLoadingBoxes = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingBoxes = false;
|
|
||||||
});
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadFilteredModels(String brand) async {
|
Future<void> _loadFilteredModels(String brand) async {
|
||||||
try {
|
try {
|
||||||
@@ -119,11 +109,26 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFilteredSubCategories(EquipmentCategory category) async {
|
||||||
|
try {
|
||||||
|
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
|
final subCategories = await equipmentProvider.loadSubCategoriesByCategory(category);
|
||||||
|
setState(() {
|
||||||
|
_filteredSubCategories = subCategories;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_filteredSubCategories = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_identifierController.dispose();
|
_identifierController.dispose();
|
||||||
_brandController.dispose();
|
_brandController.dispose();
|
||||||
_modelController.dispose();
|
_modelController.dispose();
|
||||||
|
_subCategoryController.dispose();
|
||||||
_purchasePriceController.dispose();
|
_purchasePriceController.dispose();
|
||||||
_rentalPriceController.dispose();
|
_rentalPriceController.dispose();
|
||||||
_totalQuantityController.dispose();
|
_totalQuantityController.dispose();
|
||||||
@@ -282,7 +287,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
if (value != null) {
|
if (value != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategory = value;
|
_selectedCategory = value;
|
||||||
|
_subCategoryController.clear();
|
||||||
});
|
});
|
||||||
|
_loadFilteredSubCategories(value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -318,6 +325,19 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Sous-catégorie
|
||||||
|
SubCategorySelector(
|
||||||
|
controller: _subCategoryController,
|
||||||
|
selectedCategory: _selectedCategory,
|
||||||
|
filteredSubCategories: _filteredSubCategories,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
// La valeur est déjà dans le controller
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Prix
|
// Prix
|
||||||
if (hasManagePermission) ...[
|
if (hasManagePermission) ...[
|
||||||
Row(
|
Row(
|
||||||
@@ -389,15 +409,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Boîtes parentes
|
|
||||||
const Divider(),
|
|
||||||
const Text('Boîtes parentes', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_isLoadingBoxes
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _buildParentBoxesSelector(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
@@ -448,38 +459,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildParentBoxesSelector() {
|
|
||||||
if (_availableBoxes.isEmpty) {
|
|
||||||
return const Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16.0),
|
|
||||||
child: Text('Aucune boîte disponible'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Column(
|
|
||||||
children: _availableBoxes.map((box) {
|
|
||||||
final isSelected = _selectedParentBoxIds.contains(box.id);
|
|
||||||
return CheckboxListTile(
|
|
||||||
title: Text(box.name),
|
|
||||||
subtitle: box.model != null ? Text('Modèle: {box.model}') : null,
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (bool? value) {
|
|
||||||
setState(() {
|
|
||||||
if (value == true) {
|
|
||||||
_selectedParentBoxIds.add(box.id);
|
|
||||||
} else {
|
|
||||||
_selectedParentBoxIds.remove(box.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
|
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@@ -617,6 +596,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
brand: brand,
|
brand: brand,
|
||||||
model: model,
|
model: model,
|
||||||
category: _selectedCategory,
|
category: _selectedCategory,
|
||||||
|
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
|
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
|
||||||
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
|
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
|
||||||
@@ -625,17 +605,13 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
purchaseDate: _purchaseDate,
|
purchaseDate: _purchaseDate,
|
||||||
lastMaintenanceDate: _lastMaintenanceDate,
|
lastMaintenanceDate: _lastMaintenanceDate,
|
||||||
nextMaintenanceDate: _nextMaintenanceDate,
|
nextMaintenanceDate: _nextMaintenanceDate,
|
||||||
parentBoxIds: _selectedParentBoxIds,
|
|
||||||
notes: _notesController.text,
|
notes: _notesController.text,
|
||||||
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
availableQuantity: availableQuantity,
|
availableQuantity: availableQuantity,
|
||||||
);
|
);
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
await equipmentProvider.updateEquipment(
|
await equipmentProvider.updateEquipment(equipment);
|
||||||
equipment.id,
|
|
||||||
equipment.toMap(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await equipmentProvider.addEquipment(equipment);
|
await equipmentProvider.addEquipment(equipment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,20 @@ import 'package:em2rp/utils/permission_gate.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
import 'package:em2rp/views/equipment_detail_page.dart';
|
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
class EquipmentManagementPage extends StatefulWidget {
|
class EquipmentManagementPage extends StatefulWidget {
|
||||||
const EquipmentManagementPage({super.key});
|
const EquipmentManagementPage({super.key});
|
||||||
@@ -25,12 +32,63 @@ class EquipmentManagementPage extends StatefulWidget {
|
|||||||
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||||
with SelectionModeMixin<EquipmentManagementPage> {
|
with SelectionModeMixin<EquipmentManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
List<EquipmentModel>? _cachedEquipment;
|
List<EquipmentModel>? _cachedEquipment;
|
||||||
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
DebugLog.info('[EquipmentManagementPage] initState called');
|
||||||
|
|
||||||
|
// Activer le mode pagination
|
||||||
|
final provider = context.read<EquipmentProvider>();
|
||||||
|
provider.enablePagination();
|
||||||
|
|
||||||
|
// Ajouter le listener de scroll pour le chargement infini
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Charger la première page au démarrage
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
DebugLog.info('[EquipmentManagementPage] Loading first page...');
|
||||||
|
provider.loadFirstPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
// Éviter les appels multiples avec un flag simple (sans setState)
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
|
final provider = context.read<EquipmentProvider>();
|
||||||
|
|
||||||
|
// Charger la page suivante quand on arrive à 300px du bas
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
|
||||||
|
// Vérifier qu'on peut charger plus
|
||||||
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
|
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||||
|
_isLoadingMore = true;
|
||||||
|
|
||||||
|
provider.loadNextPage().then((_) {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}).catchError((error) {
|
||||||
|
_isLoadingMore = false;
|
||||||
|
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
// Désactiver le mode pagination en quittant
|
||||||
|
context.read<EquipmentProvider>().disablePagination();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +125,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
const NotificationBadge(),
|
||||||
if (hasSelection) ...[
|
if (hasSelection) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||||
@@ -83,13 +142,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
)
|
)
|
||||||
: CustomAppBar(
|
: CustomAppBar(
|
||||||
title: 'Gestion du matériel',
|
title: 'Gestion du matériel',
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.checklist),
|
|
||||||
tooltip: 'Mode sélection',
|
|
||||||
onPressed: toggleSelectionMode,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||||
@@ -113,50 +165,39 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Barre de recherche et bouton boîtes
|
// Barre de recherche et boutons d'action
|
||||||
Padding(
|
SearchActionsBar(
|
||||||
padding: const EdgeInsets.all(16.0),
|
controller: _searchController,
|
||||||
child: Row(
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
children: [
|
onChanged: (value) {
|
||||||
Expanded(
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||||
child: TextField(
|
},
|
||||||
controller: _searchController,
|
onClear: () {
|
||||||
decoration: InputDecoration(
|
_searchController.clear();
|
||||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
prefixIcon: const Icon(Icons.search),
|
},
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
actions: [
|
||||||
? IconButton(
|
IconButton.filled(
|
||||||
icon: const Icon(Icons.clear),
|
onPressed: _scanQRCode,
|
||||||
onPressed: () {
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
_searchController.clear();
|
tooltip: 'Scanner un QR Code',
|
||||||
context.read<EquipmentProvider>().setSearchQuery('');
|
style: IconButton.styleFrom(
|
||||||
},
|
backgroundColor: Colors.grey[700],
|
||||||
)
|
foregroundColor: Colors.white,
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
// Bouton Gérer les boîtes
|
IconButton.filled(
|
||||||
IconButton.filled(
|
onPressed: () {
|
||||||
onPressed: () {
|
Navigator.pushNamed(context, '/container_management');
|
||||||
Navigator.pushNamed(context, '/container_management');
|
},
|
||||||
},
|
icon: const Icon(Icons.inventory_2),
|
||||||
icon: const Icon(Icons.inventory_2),
|
tooltip: 'Gérer les boîtes',
|
||||||
tooltip: 'Gérer les boîtes',
|
style: IconButton.styleFrom(
|
||||||
style: IconButton.styleFrom(
|
backgroundColor: AppColors.rouge,
|
||||||
backgroundColor: AppColors.rouge,
|
foregroundColor: Colors.white,
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
// Menu horizontal de filtres par catégorie
|
// Menu horizontal de filtres par catégorie
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -221,29 +262,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Bouton Gérer les boîtes
|
const SizedBox(height: 16),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pushNamed(context, '/container_management');
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.inventory_2, color: Colors.white),
|
|
||||||
label: const Text(
|
|
||||||
'Gérer les boîtes',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
minimumSize: const Size(double.infinity, 50),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
// En-tête filtres
|
// En-tête filtres
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -264,37 +283,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Barre de recherche
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Rechercher...',
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 20),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
context
|
|
||||||
.read<EquipmentProvider>()
|
|
||||||
.setSearchQuery('');
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
isDense: true,
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Filtres par catégorie
|
// Filtres par catégorie
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding:
|
||||||
@@ -348,7 +336,56 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Contenu principal
|
// Contenu principal
|
||||||
Expanded(child: _buildEquipmentList()),
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SearchActionsBar(
|
||||||
|
controller: _searchController,
|
||||||
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
|
onChanged: (value) {
|
||||||
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||||
|
},
|
||||||
|
onClear: () {
|
||||||
|
_searchController.clear();
|
||||||
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: _scanQRCode,
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
tooltip: 'Scanner un QR Code',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(context, '/container_management');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.inventory_2),
|
||||||
|
tooltip: 'Gérer les boîtes',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSelectionMode)
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: toggleSelectionMode,
|
||||||
|
icon: const Icon(Icons.checklist),
|
||||||
|
tooltip: 'Mode sélection',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(child: _buildEquipmentList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -419,19 +456,65 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentList() {
|
Widget _buildEquipmentList() {
|
||||||
return Consumer<EquipmentProvider>(
|
return Consumer<EquipmentProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
return ManagementList<EquipmentModel>(
|
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||||
stream: provider.equipmentStream,
|
|
||||||
cachedItems: _cachedEquipment,
|
// Afficher l'indicateur de chargement initial uniquement
|
||||||
emptyMessage: 'Aucun équipement trouvé',
|
if (provider.isLoading && provider.equipment.isEmpty) {
|
||||||
emptyIcon: Icons.inventory_2_outlined,
|
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
|
||||||
onDataReceived: (items) {
|
return const Center(child: CircularProgressIndicator());
|
||||||
_cachedEquipment = items;
|
}
|
||||||
},
|
|
||||||
itemBuilder: (equipment) {
|
final equipments = provider.equipment;
|
||||||
// Trier les équipements par nom
|
|
||||||
final sortedEquipment = List<EquipmentModel>.from(_cachedEquipment ?? [equipment]);
|
if (equipments.isEmpty && !provider.isLoading) {
|
||||||
sortedEquipment.sort((a, b) => a.name.compareTo(b.name));
|
DebugLog.info('[EquipmentManagementPage] No equipment found');
|
||||||
return _buildEquipmentCard(equipment);
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun équipement trouvé',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||||
|
|
||||||
|
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
||||||
|
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: itemCount,
|
||||||
|
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
|
||||||
|
// Note : À ajuster selon la hauteur réelle de vos cartes
|
||||||
|
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
|
||||||
|
// ✅ Augmenter le cache pour un scroll plus fluide
|
||||||
|
cacheExtent: 500, // Précharger 500px en plus
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// Dernier élément = indicateur de chargement
|
||||||
|
if (index == equipments.length) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildEquipmentCard(equipments[index]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -441,67 +524,81 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
||||||
final isSelected = isItemSelected(equipment.id);
|
final isSelected = isItemSelected(equipment.id);
|
||||||
|
|
||||||
return Card(
|
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
return RepaintBoundary(
|
||||||
color: isSelectionMode && isSelected
|
key: ValueKey(equipment.id),
|
||||||
? AppColors.rouge.withOpacity(0.1)
|
child: Card(
|
||||||
: null,
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
color: isSelectionMode && isSelected
|
||||||
leading: isSelectionMode
|
? AppColors.rouge.withValues(alpha: 0.1)
|
||||||
? Checkbox(
|
: null,
|
||||||
value: isSelected,
|
child: ListTile(
|
||||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
leading: isSelectionMode
|
||||||
activeColor: AppColors.rouge,
|
? Checkbox(
|
||||||
)
|
value: isSelected,
|
||||||
: CircleAvatar(
|
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||||
backgroundColor:
|
activeColor: AppColors.rouge,
|
||||||
equipment.status.color.withOpacity(0.2),
|
)
|
||||||
child: equipment.category.getIcon(
|
: CircleAvatar(
|
||||||
size: 20,
|
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||||
color: Colors.black,
|
child: equipment.category.getIcon(
|
||||||
|
size: 20,
|
||||||
|
color: equipment.category.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
equipment.id,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Row(
|
// Afficher le badge de statut calculé dynamiquement
|
||||||
children: [
|
if (equipment.category != EquipmentCategory.consumable &&
|
||||||
Expanded(
|
equipment.category != EquipmentCategory.cable)
|
||||||
child: Text(
|
EquipmentStatusBadge(equipment: equipment),
|
||||||
equipment.id,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
|
|
||||||
if (equipment.category != EquipmentCategory.consumable &&
|
|
||||||
equipment.category != EquipmentCategory.cable)
|
|
||||||
_buildStatusBadge(equipment.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
|
||||||
.trim()
|
|
||||||
.isNotEmpty
|
|
||||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
|
||||||
: 'Marque/Modèle non défini',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
|
||||||
),
|
|
||||||
// Afficher la quantité disponible pour les consommables/câbles
|
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
|
||||||
equipment.category == EquipmentCategory.cable) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_buildQuantityDisplay(equipment),
|
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
subtitle: Column(
|
||||||
trailing: isSelectionMode
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? null
|
children: [
|
||||||
: Row(
|
const SizedBox(height: 4),
|
||||||
mainAxisSize: MainAxisSize.min,
|
Text(
|
||||||
children: [
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
.trim()
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
.isNotEmpty
|
||||||
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||||
|
: 'Marque/Modèle non défini',
|
||||||
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
|
),
|
||||||
|
// Afficher la sous-catégorie si elle existe
|
||||||
|
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'📁 ${equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Afficher la quantité disponible pour les consommables/câbles
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildQuantityDisplay(equipment),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelectionMode
|
||||||
|
? null
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
equipment.category == EquipmentCategory.cable)
|
equipment.category == EquipmentCategory.cable)
|
||||||
PermissionGate(
|
PermissionGate(
|
||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
@@ -545,6 +642,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
? () => toggleItemSelection(equipment.id)
|
? () => toggleItemSelection(equipment.id)
|
||||||
: () => _viewEquipmentDetails(equipment),
|
: () => _viewEquipmentDetails(equipment),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,24 +705,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusBadge(EquipmentStatus status) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: status.color.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: status.color),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
status.label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: status.color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
void _createNewEquipment() {
|
void _createNewEquipment() {
|
||||||
@@ -737,39 +817,75 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
void _generateQRCodesForSelected() async {
|
void _generateQRCodesForSelected() async {
|
||||||
if (!hasSelection) return;
|
if (!hasSelection) return;
|
||||||
|
|
||||||
// Récupérer les équipements sélectionnés
|
// Afficher un indicateur de chargement
|
||||||
final provider = context.read<EquipmentProvider>();
|
showDialog(
|
||||||
final List<EquipmentModel> selectedEquipment = [];
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// On doit récupérer les équipements depuis le stream
|
try {
|
||||||
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
// Récupérer les équipements sélectionnés
|
||||||
for (final equipment in equipmentList) {
|
final provider = context.read<EquipmentProvider>();
|
||||||
if (isItemSelected(equipment.id)) {
|
final List<EquipmentModel> selectedEquipment = [];
|
||||||
selectedEquipment.add(equipment);
|
|
||||||
|
// On doit récupérer les équipements depuis le stream
|
||||||
|
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
||||||
|
for (final equipment in equipmentList) {
|
||||||
|
if (isItemSelected(equipment.id)) {
|
||||||
|
selectedEquipment.add(equipment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer l'indicateur de chargement
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEquipment.isEmpty) return;
|
||||||
|
|
||||||
|
if (selectedEquipment.length == 1) {
|
||||||
|
// Un seul équipement : afficher le dialogue simple
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plusieurs équipements : afficher le sélecteur de format
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
|
||||||
|
itemList: selectedEquipment,
|
||||||
|
getId: (eq) => eq.id,
|
||||||
|
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
||||||
|
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
} catch (e) {
|
||||||
}
|
// Fermer l'indicateur si une erreur survient
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedEquipment.isEmpty) return;
|
DebugLog.error('[EquipmentManagementPage] Error generating QR codes', e);
|
||||||
|
|
||||||
if (selectedEquipment.length == 1) {
|
if (mounted) {
|
||||||
// Un seul équipement : afficher le dialogue simple
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
showDialog(
|
SnackBar(
|
||||||
context: context,
|
content: Text('Erreur lors de la génération : ${e.toString()}'),
|
||||||
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
backgroundColor: Colors.red,
|
||||||
);
|
),
|
||||||
} else {
|
);
|
||||||
// Plusieurs équipements : afficher le sélecteur de format
|
}
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
|
|
||||||
itemList: selectedEquipment,
|
|
||||||
getId: (eq) => eq.id,
|
|
||||||
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
|
||||||
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,10 +1040,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
'updatedAt': DateTime.now().toIso8601String(),
|
'updatedAt': DateTime.now().toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await context.read<EquipmentProvider>().updateEquipment(
|
final updatedEquipment = equipment.copyWith(
|
||||||
equipment.id,
|
availableQuantity: newAvailable,
|
||||||
updatedData,
|
totalQuantity: newTotal,
|
||||||
);
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -970,4 +1089,119 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scanner un QR Code et ouvrir la vue de détail correspondante
|
||||||
|
Future<void> _scanQRCode() async {
|
||||||
|
try {
|
||||||
|
// Ouvrir le scanner
|
||||||
|
final scannedCode = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const QRCodeScannerDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scannedCode == null || scannedCode.isEmpty) {
|
||||||
|
return; // L'utilisateur a annulé
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Afficher un indicateur de chargement
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechercher d'abord dans les équipements
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
|
||||||
|
final equipment = equipmentProvider.allEquipment.firstWhere(
|
||||||
|
(eq) => eq.id == scannedCode,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(); // Fermer l'indicateur
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment.id.isNotEmpty) {
|
||||||
|
// Équipement trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas trouvé dans les équipements, chercher dans les conteneurs
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
if (containerProvider.containers.isEmpty) {
|
||||||
|
await containerProvider.loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerProvider.containers.firstWhere(
|
||||||
|
(c) => c.id == scannedCode,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (container.id.isNotEmpty) {
|
||||||
|
// Conteneur trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ContainerDetailPage(container: container),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rien trouvé
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e);
|
||||||
|
if (mounted) {
|
||||||
|
// Fermer l'indicateur si ouvert
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur lors du scan : ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user