Compare commits
47 Commits
acab16e101
...
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 | ||
|
|
fa1d6a4295 | ||
|
|
df9e24d3b3 | ||
|
|
28d9e008af | ||
|
|
08f046c89c | ||
|
|
e59e3e6316 | ||
|
|
6abb8f1d14 | ||
|
|
822d4443f9 | ||
|
|
df6d54a007 | ||
|
|
3fab69cb00 | ||
|
|
ae3a1b7227 | ||
|
|
ef638d8c8c | ||
|
|
5057bf9a77 | ||
|
|
f10a608801 | ||
|
|
4128ddc34a | ||
|
|
aae68f8ab7 | ||
| 080fb7d077 | |||
| 57c59c911a |
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
@@ -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.
|
||||||
6
em2rp/.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
|
||||||
|
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
|
||||||
|
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
|
||||||
|
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
|
||||||
|
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
|
||||||
|
Ne pas générer de fichier résumant le code généré.
|
||||||
4
em2rp/.gitignore
vendored
@@ -41,3 +41,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Environment configuration with credentials
|
||||||
|
lib/config/env.dev.dart
|
||||||
|
functions/.env
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# em2rp
|
|
||||||
|
|
||||||
A new Flutter project.
|
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.9 KiB |
35
em2rp/assets/icons/flight-case.svg
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1793 5032 c-17 -9 -82 -75 -144 -147 l-112 -130 511 -3 c282 -1 742
|
||||||
|
-1 1024 0 l511 3 -112 130 c-62 72 -127 138 -144 147 -29 16 -93 17 -767 17
|
||||||
|
-674 0 -738 -1 -767 -17z"/>
|
||||||
|
<path d="M76 4426 c-75 -45 -75 -46 -76 -443 l0 -353 410 0 410 0 0 34 c0 43
|
||||||
|
36 96 80 118 31 16 71 18 363 18 356 0 374 -2 416 -56 12 -15 26 -47 32 -71
|
||||||
|
l11 -43 842 0 842 0 2 32 c3 41 29 85 65 112 27 20 41 21 360 24 217 2 345 -1
|
||||||
|
369 -8 46 -13 85 -59 99 -116 l11 -44 404 0 404 0 0 351 c0 345 0 352 -22 391
|
||||||
|
-14 24 -38 48 -60 59 -36 19 -101 19 -2480 19 l-2443 0 -39 -24z"/>
|
||||||
|
<path d="M1120 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||||
|
<path d="M3710 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||||
|
<path d="M0 3088 l0 -243 240 240 c132 132 240 241 240 242 0 2 -108 3 -240 3
|
||||||
|
l-240 0 0 -242z"/>
|
||||||
|
<path d="M1718 3118 c-3 -234 -8 -255 -71 -302 -27 -20 -41 -21 -370 -24 -382
|
||||||
|
-3 -388 -2 -434 67 -22 32 -23 44 -23 205 l0 171 -410 -410 -410 -410 0 -460
|
||||||
|
0 -460 601 -608 601 -607 1350 0 1350 0 609 602 609 603 0 470 1 470 -403 397
|
||||||
|
-403 397 -5 -157 c-6 -172 -15 -202 -73 -246 -27 -20 -41 -21 -370 -24 -382
|
||||||
|
-3 -388 -2 -434 67 -22 33 -23 42 -23 252 l0 219 -844 0 -845 0 -3 -212z"/>
|
||||||
|
<path d="M4875 3090 c132 -132 241 -240 242 -240 2 0 3 108 3 240 l0 240 -242
|
||||||
|
0 -243 0 240 -240z"/>
|
||||||
|
<path d="M0 731 c0 -321 1 -335 21 -371 40 -71 71 -80 283 -80 l186 0 0 -45
|
||||||
|
c0 -100 56 -160 147 -160 85 0 143 57 150 147 l4 53 -395 395 -396 395 0 -334z"/>
|
||||||
|
<path d="M4723 668 c-384 -384 -393 -394 -393 -432 0 -63 34 -121 84 -145 54
|
||||||
|
-27 81 -26 136 2 54 27 80 72 80 140 l0 47 185 0 c214 0 243 8 283 78 22 40
|
||||||
|
22 44 20 371 l-3 331 -392 -392z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
32
em2rp/assets/icons/tape.svg
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1979 5105 c-272 -46 -519 -179 -746 -403 -407 -401 -673 -1072 -701
|
||||||
|
-1767 -26 -644 82 -1147 345 -1610 131 -232 154 -409 69 -544 -39 -61 -145
|
||||||
|
-159 -201 -185 -104 -49 -330 -113 -585 -167 -58 -12 -106 -23 -108 -24 -4 -4
|
||||||
|
20 -99 27 -107 3 -4 59 0 124 7 160 19 167 16 231 -112 l36 -72 122 2 c140 2
|
||||||
|
157 -3 239 -80 l47 -44 116 26 c373 84 861 246 1020 339 94 55 141 166 139
|
||||||
|
331 -1 171 -47 317 -160 512 -254 440 -373 934 -373 1553 0 259 15 440 51 639
|
||||||
|
131 720 509 1334 1049 1703 24 16 8 17 -320 16 -231 0 -370 -4 -421 -13z"/>
|
||||||
|
<path d="M3276 5109 c-322 -47 -636 -237 -884 -534 -759 -909 -791 -2555 -68
|
||||||
|
-3530 86 -116 295 -319 401 -392 457 -309 952 -312 1410 -7 103 68 271 226
|
||||||
|
362 338 288 357 477 830 550 1381 24 182 24 628 0 810 -151 1141 -829 1953
|
||||||
|
-1622 1944 -49 -1 -116 -5 -149 -10z m399 -1047 c227 -79 439 -286 570 -557
|
||||||
|
282 -586 185 -1369 -222 -1796 -284 -297 -634 -357 -971 -167 -102 57 -260
|
||||||
|
212 -337 331 -337 521 -335 1288 7 1802 59 89 198 235 269 283 68 46 175 98
|
||||||
|
243 117 135 39 304 34 441 -13z"/>
|
||||||
|
<path d="M3307 3905 c-61 -15 -158 -60 -210 -97 -43 -30 -145 -127 -168 -159
|
||||||
|
l-19 -27 55 -113 c65 -138 107 -261 137 -411 19 -97 23 -144 23 -328 0 -184
|
||||||
|
-3 -231 -23 -328 -31 -152 -81 -302 -142 -423 -38 -76 -48 -105 -40 -118 5
|
||||||
|
-10 48 -54 95 -99 131 -125 263 -182 419 -182 350 0 651 328 756 825 27 130
|
||||||
|
37 373 21 514 -44 372 -212 694 -449 855 -132 90 -314 127 -455 91z"/>
|
||||||
|
<path d="M2327 628 c-3 -74 -11 -140 -20 -165 l-16 -43 226 0 226 0 -79 51
|
||||||
|
c-94 61 -164 118 -260 212 l-71 69 -6 -124z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
26
em2rp/assets/icons/truss.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M410 3418 c-70 -35 -90 -85 -90 -218 1 -152 35 -214 132 -235 l28 -7
|
||||||
|
0 -398 0 -398 -28 -7 c-97 -21 -131 -83 -132 -235 0 -133 20 -183 90 -217 l44
|
||||||
|
-23 2106 0 2106 0 44 23 c70 34 90 84 90 217 -1 152 -35 214 -132 235 l-28 7
|
||||||
|
0 398 0 398 28 7 c97 21 131 83 132 235 0 133 -20 183 -90 218 l-44 22 -2106
|
||||||
|
0 -2106 0 -44 -22z m4230 -218 l0 -80 -2080 0 -2080 0 0 80 0 80 2080 0 2080
|
||||||
|
0 0 -80z m-3770 -640 c-130 -260 -205 -400 -215 -400 -13 0 -15 51 -15 400 l0
|
||||||
|
400 215 0 215 0 -200 -400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393
|
||||||
|
201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197 -393 -201 -400 -230 -400
|
||||||
|
-29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200 -400z m640 0 l200 -400 -430
|
||||||
|
0 -430 0 200 400 c197 393 201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197
|
||||||
|
-393 -201 -400 -230 -400 -29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200
|
||||||
|
-400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393 201 400 230 400 29 0
|
||||||
|
33 -7 230 -400z m410 0 c0 -349 -2 -400 -15 -400 -10 0 -85 140 -215 400
|
||||||
|
l-200 400 215 0 215 0 0 -400z m160 -640 l0 -80 -2080 0 -2080 0 0 80 0 80
|
||||||
|
2080 0 2080 0 0 -80z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/LowQRectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/RectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
em2rp/assets/logos/RectangleLogoWhite.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
em2rp/assets/logos/SquareLogoBlack.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
em2rp/assets/logos/SquareLogoWhite.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
em2rp/assets/sounds/error.mp3
Normal file
BIN
em2rp/assets/sounds/ok.mp3
Normal file
70
em2rp/deploy.bat
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour incrémenter la version et déployer sur Firebase
|
||||||
|
|
||||||
|
echo ================================================
|
||||||
|
echo Déploiement Firebase Hosting avec EM2RP
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [0/4] Basculement en mode PRODUCTION...
|
||||||
|
node scripts\toggle_env.js prod
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du basculement en mode production
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/4] Incrémentation de la version...
|
||||||
|
node scripts\increment_version.js
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors de l'incrémentation de la version
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
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...
|
||||||
|
call flutter build web --release
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du build Flutter
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [3/4] Déploiement Firebase Hosting...
|
||||||
|
call firebase deploy --only hosting
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du déploiement Firebase
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [4/4] Retour en mode DÉVELOPPEMENT...
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo ATTENTION: Impossible de rebascule en mode dev
|
||||||
|
echo Exécutez manuellement: node scripts\toggle_env.js dev
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================
|
||||||
|
echo Déploiement terminé avec succès!
|
||||||
|
echo ================================================
|
||||||
|
pause
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,3 +1,4 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
- provider: true
|
||||||
15
em2rp/env_dev.bat
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour basculer en mode DÉVELOPPEMENT
|
||||||
|
|
||||||
|
echo Basculement en mode DÉVELOPPEMENT...
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✅ Mode DÉVELOPPEMENT activé
|
||||||
|
echo - isDevelopment = true
|
||||||
|
echo - Auto-login activé
|
||||||
|
) else (
|
||||||
|
echo ❌ Erreur lors du basculement
|
||||||
|
echo Vérifiez que le fichier env.dev.dart existe
|
||||||
|
)
|
||||||
|
|
||||||
16
em2rp/env_prod.bat
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour basculer en mode PRODUCTION
|
||||||
|
|
||||||
|
echo Basculement en mode PRODUCTION...
|
||||||
|
node scripts\toggle_env.js prod
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✅ Mode PRODUCTION activé
|
||||||
|
echo - isDevelopment = false
|
||||||
|
echo - Credentials masqués
|
||||||
|
) else (
|
||||||
|
echo ❌ Erreur lors du basculement
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
|
|
||||||
@@ -34,5 +34,58 @@
|
|||||||
"*.local"
|
"*.local"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"hosting": {
|
||||||
|
"public": "build/web",
|
||||||
|
"ignore": [
|
||||||
|
"firebase.json",
|
||||||
|
"**/.*",
|
||||||
|
"**/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
@@ -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
@@ -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
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
20
em2rp/flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
background_color: "#ffffff"
|
||||||
|
theme_color: "#0175C2"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
icon_size: 48
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
linux:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
|
||||||
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
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
14
em2rp/increment_version.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour incrémenter uniquement la version
|
||||||
|
|
||||||
|
echo Incrémentation de la version...
|
||||||
|
node scripts\increment_version.js
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo Version incrémentée avec succès!
|
||||||
|
) else (
|
||||||
|
echo Erreur lors de l'incrémentation
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
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;
|
||||||
|
}
|
||||||
|
|
||||||
12
em2rp/lib/config/app_version.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// Configuration de la version de l'application
|
||||||
|
class AppVersion {
|
||||||
|
static const String version = '1.1.14';
|
||||||
|
|
||||||
|
/// Retourne la version complète de l'application
|
||||||
|
static String get fullVersion => 'v$version';
|
||||||
|
|
||||||
|
|
||||||
|
/// Retourne la version avec un préfixe personnalisé
|
||||||
|
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,8 +3,7 @@ class Env {
|
|||||||
|
|
||||||
// Configuration de l'auto-login en développement
|
// Configuration de l'auto-login en développement
|
||||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||||
static const String devAdminPassword =
|
static const String devAdminPassword = 'Pastis51!';
|
||||||
"Azerty\$1!"; // À remplacer par le vrai mot de passe
|
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -15,3 +14,4 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
em2rp/lib/config/env.dev.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class Env {
|
||||||
|
static const bool isDevelopment = true;
|
||||||
|
|
||||||
|
// Configuration de l'auto-login en développement
|
||||||
|
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||||
|
static const String devAdminPassword = 'Pastis51!';
|
||||||
|
|
||||||
|
// URLs et endpoints
|
||||||
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
|
|
||||||
|
// Configuration Firebase
|
||||||
|
static const String firebaseProjectId = 'em2rp-951dc';
|
||||||
|
|
||||||
|
// Autres configurations
|
||||||
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
|
}
|
||||||
|
|
||||||
483
em2rp/lib/controllers/event_form_controller.dart
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
|
import 'package:em2rp/models/user_model.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:em2rp/providers/event_provider.dart';
|
||||||
|
|
||||||
|
class EventFormController extends ChangeNotifier {
|
||||||
|
// Controllers
|
||||||
|
final TextEditingController nameController = TextEditingController();
|
||||||
|
final TextEditingController descriptionController = TextEditingController();
|
||||||
|
final TextEditingController basePriceController = TextEditingController();
|
||||||
|
final TextEditingController installationController = TextEditingController();
|
||||||
|
final TextEditingController disassemblyController = TextEditingController();
|
||||||
|
final TextEditingController addressController = TextEditingController();
|
||||||
|
final TextEditingController jaugeController = TextEditingController();
|
||||||
|
final TextEditingController contactEmailController = TextEditingController();
|
||||||
|
final TextEditingController contactPhoneController = TextEditingController();
|
||||||
|
|
||||||
|
// State variables
|
||||||
|
DateTime? _startDateTime;
|
||||||
|
DateTime? _endDateTime;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
String? _success;
|
||||||
|
String? _selectedEventTypeId;
|
||||||
|
List<EventTypeModel> _eventTypes = [];
|
||||||
|
bool _isLoadingEventTypes = true;
|
||||||
|
List<String> _selectedUserIds = [];
|
||||||
|
List<UserModel> _allUsers = [];
|
||||||
|
bool _isLoadingUsers = true;
|
||||||
|
List<Map<String, String>> _uploadedFiles = [];
|
||||||
|
List<Map<String, dynamic>> _selectedOptions = [];
|
||||||
|
bool _formChanged = false;
|
||||||
|
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
List<EventEquipment> _assignedEquipment = [];
|
||||||
|
List<String> _assignedContainers = [];
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
DateTime? get startDateTime => _startDateTime;
|
||||||
|
DateTime? get endDateTime => _endDateTime;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String? get success => _success;
|
||||||
|
String? get selectedEventTypeId => _selectedEventTypeId;
|
||||||
|
List<EventTypeModel> get eventTypes => _eventTypes;
|
||||||
|
bool get isLoadingEventTypes => _isLoadingEventTypes;
|
||||||
|
List<String> get selectedUserIds => _selectedUserIds;
|
||||||
|
List<UserModel> get allUsers => _allUsers;
|
||||||
|
bool get isLoadingUsers => _isLoadingUsers;
|
||||||
|
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
||||||
|
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
||||||
|
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
||||||
|
List<String> get assignedContainers => _assignedContainers;
|
||||||
|
bool get formChanged => _formChanged;
|
||||||
|
EventStatus get selectedStatus => _selectedStatus;
|
||||||
|
|
||||||
|
EventFormController() {
|
||||||
|
_setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupListeners() {
|
||||||
|
nameController.addListener(_onAnyFieldChanged);
|
||||||
|
basePriceController.addListener(_onAnyFieldChanged);
|
||||||
|
installationController.addListener(_onAnyFieldChanged);
|
||||||
|
disassemblyController.addListener(_onAnyFieldChanged);
|
||||||
|
addressController.addListener(_onAnyFieldChanged);
|
||||||
|
descriptionController.addListener(_onAnyFieldChanged);
|
||||||
|
jaugeController.addListener(_onAnyFieldChanged);
|
||||||
|
contactEmailController.addListener(_onAnyFieldChanged);
|
||||||
|
contactPhoneController.addListener(_onAnyFieldChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnyFieldChanged() {
|
||||||
|
if (!_formChanged) {
|
||||||
|
_formChanged = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
||||||
|
await Future.wait([
|
||||||
|
_fetchUsers(),
|
||||||
|
_fetchEventTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (existingEvent != null) {
|
||||||
|
// 🔧 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 {
|
||||||
|
_selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
|
||||||
|
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
||||||
|
if (selectedDate != null) {
|
||||||
|
// Date de début : selectedDate à 20h00
|
||||||
|
_startDateTime = DateTime(
|
||||||
|
selectedDate.year,
|
||||||
|
selectedDate.month,
|
||||||
|
selectedDate.day,
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Date de fin : selectedDate + 4 heures
|
||||||
|
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _populateFromEvent(EventModel event) {
|
||||||
|
nameController.text = event.name;
|
||||||
|
descriptionController.text = event.description;
|
||||||
|
basePriceController.text = event.basePrice.toStringAsFixed(2);
|
||||||
|
installationController.text = event.installationTime.toString();
|
||||||
|
disassemblyController.text = event.disassemblyTime.toString();
|
||||||
|
addressController.text = event.address;
|
||||||
|
jaugeController.text = event.jauge?.toString() ?? '';
|
||||||
|
contactEmailController.text = event.contactEmail ?? '';
|
||||||
|
contactPhoneController.text = event.contactPhone ?? '';
|
||||||
|
_startDateTime = event.startDateTime;
|
||||||
|
_endDateTime = event.endDateTime;
|
||||||
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||||
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||||
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
||||||
|
_selectedStatus = event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchUsers() async {
|
||||||
|
try {
|
||||||
|
_allUsers = await EventFormService.fetchUsers();
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchEventTypes() async {
|
||||||
|
try {
|
||||||
|
_eventTypes = await EventFormService.fetchEventTypes();
|
||||||
|
_isLoadingEventTypes = false;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingEventTypes = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setStartDateTime(DateTime? dateTime) {
|
||||||
|
_startDateTime = dateTime;
|
||||||
|
if (_endDateTime != null &&
|
||||||
|
dateTime != null &&
|
||||||
|
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
|
||||||
|
_endDateTime = null;
|
||||||
|
}
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEndDateTime(DateTime? dateTime) {
|
||||||
|
_endDateTime = dateTime;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEventTypeChanged(String? newTypeId, BuildContext context) {
|
||||||
|
if (newTypeId == _selectedEventTypeId) return;
|
||||||
|
|
||||||
|
final oldEventTypeIndex = _selectedEventTypeId != null
|
||||||
|
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
|
||||||
|
: -1;
|
||||||
|
final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
|
||||||
|
|
||||||
|
_selectedEventTypeId = newTypeId;
|
||||||
|
|
||||||
|
if (newTypeId != null) {
|
||||||
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||||
|
|
||||||
|
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||||
|
final defaultPriceTTC = selectedType.defaultPrice;
|
||||||
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||||
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||||
|
|
||||||
|
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||||
|
if (basePriceController.text.isEmpty ||
|
||||||
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||||
|
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||||
|
final before = _selectedOptions.length;
|
||||||
|
_selectedOptions.removeWhere((opt) {
|
||||||
|
// Vérifier si cette option est compatible avec le type d'événement sélectionné
|
||||||
|
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
|
||||||
|
return !optionEventTypes.contains(selectedType.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_selectedOptions.length < before) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedOptions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectedUserIds(List<String> userIds) {
|
||||||
|
_selectedUserIds = userIds;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUploadedFiles(List<Map<String, String>> files) {
|
||||||
|
_uploadedFiles = files;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectedOptions(List<Map<String, dynamic>> options) {
|
||||||
|
_selectedOptions = options;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||||
|
_assignedEquipment = equipment;
|
||||||
|
_assignedContainers = containers;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickAndUploadFiles() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final files = await EventFormService.uploadFiles(result.files);
|
||||||
|
_uploadedFiles.addAll(files);
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Erreur lors de l\'upload : $e';
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validateForm() {
|
||||||
|
return nameController.text.isNotEmpty &&
|
||||||
|
_startDateTime != null &&
|
||||||
|
_endDateTime != null &&
|
||||||
|
_selectedEventTypeId != null &&
|
||||||
|
addressController.text.isNotEmpty &&
|
||||||
|
(_endDateTime!.isAfter(_startDateTime!));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
|
||||||
|
if (!validateForm()) {
|
||||||
|
_error = "Veuillez remplir tous les champs obligatoires.";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final eventTypeRef = _selectedEventTypeId != null
|
||||||
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existingEvent != null) {
|
||||||
|
// Mode édition
|
||||||
|
// Gérer les nouveaux fichiers uploadés s'il y en a
|
||||||
|
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
|
||||||
|
|
||||||
|
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
|
||||||
|
final newFiles = _uploadedFiles.where((file) =>
|
||||||
|
file['url']?.contains('events/temp/') ?? false).toList();
|
||||||
|
|
||||||
|
if (newFiles.isNotEmpty) {
|
||||||
|
// Déplacer les nouveaux fichiers vers le dossier de l'événement
|
||||||
|
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
|
||||||
|
|
||||||
|
// Remplacer les URLs temporaires par les nouvelles URLs
|
||||||
|
for (int i = 0; i < finalDocuments.length; i++) {
|
||||||
|
final tempFile = finalDocuments[i];
|
||||||
|
final movedFile = movedFiles.firstWhere(
|
||||||
|
(moved) => moved['name'] == tempFile['name'],
|
||||||
|
orElse: () => tempFile,
|
||||||
|
);
|
||||||
|
finalDocuments[i] = movedFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedEvent = EventModel(
|
||||||
|
id: existingEvent.id,
|
||||||
|
name: nameController.text.trim(),
|
||||||
|
description: descriptionController.text.trim(),
|
||||||
|
startDateTime: _startDateTime!,
|
||||||
|
endDateTime: _endDateTime!,
|
||||||
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||||
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||||
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||||
|
eventTypeId: _selectedEventTypeId!,
|
||||||
|
eventTypeRef: eventTypeRef,
|
||||||
|
customerId: existingEvent.customerId,
|
||||||
|
address: addressController.text.trim(),
|
||||||
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
workforce: _selectedUserIds,
|
||||||
|
latitude: existingEvent.latitude,
|
||||||
|
longitude: existingEvent.longitude,
|
||||||
|
documents: finalDocuments,
|
||||||
|
options: _selectedOptions,
|
||||||
|
status: _selectedStatus,
|
||||||
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||||
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||||
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
preparationStatus: existingEvent.preparationStatus,
|
||||||
|
returnStatus: existingEvent.returnStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
await EventFormService.updateEvent(updatedEvent);
|
||||||
|
|
||||||
|
// Mettre à jour l'événement dans le cache (au lieu de tout recharger)
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
await eventProvider.updateEvent(updatedEvent);
|
||||||
|
|
||||||
|
_success = "Événement modifié avec succès !";
|
||||||
|
} else {
|
||||||
|
// Mode création
|
||||||
|
final newEvent = EventModel(
|
||||||
|
id: '',
|
||||||
|
name: nameController.text.trim(),
|
||||||
|
description: descriptionController.text.trim(),
|
||||||
|
startDateTime: _startDateTime!,
|
||||||
|
endDateTime: _endDateTime!,
|
||||||
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||||
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||||
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||||
|
eventTypeId: _selectedEventTypeId!,
|
||||||
|
eventTypeRef: eventTypeRef,
|
||||||
|
customerId: '',
|
||||||
|
address: addressController.text.trim(),
|
||||||
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
workforce: _selectedUserIds,
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
documents: _uploadedFiles,
|
||||||
|
options: _selectedOptions,
|
||||||
|
status: _selectedStatus,
|
||||||
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||||
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||||
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
|
);
|
||||||
|
|
||||||
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
|
|
||||||
|
// Créer l'événement avec l'ID retourné
|
||||||
|
EventModel createdEvent = newEvent.copyWith(id: eventId);
|
||||||
|
|
||||||
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
||||||
|
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 !";
|
||||||
|
}
|
||||||
|
|
||||||
|
_formChanged = false;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = "Erreur lors de la sauvegarde : $e";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supprimer l'événement via le provider (qui appelle l'API et met à jour le cache)
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
await eventProvider.deleteEvent(eventId);
|
||||||
|
|
||||||
|
_success = "Événement supprimé avec succès !";
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = "Erreur lors de la suppression : $e";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearError() {
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSuccess() {
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
nameController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
|
basePriceController.dispose();
|
||||||
|
installationController.dispose();
|
||||||
|
disassemblyController.dispose();
|
||||||
|
addressController.dispose();
|
||||||
|
jaugeController.dispose();
|
||||||
|
contactEmailController.dispose();
|
||||||
|
contactPhoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
import 'package:em2rp/providers/users_provider.dart';
|
import 'package:em2rp/providers/users_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
|
import 'package:em2rp/providers/maintenance_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/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_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/event_preparation_page.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';
|
||||||
@@ -12,37 +27,69 @@ 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 'pages/auth/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(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// EquipmentProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<EquipmentProvider>(
|
||||||
|
create: (context) => EquipmentProvider(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ContainerProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<ContainerProvider>(
|
||||||
|
create: (context) => ContainerProvider(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// MaintenanceProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<MaintenanceProvider>(
|
||||||
|
create: (context) => MaintenanceProvider(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<AlertProvider>(
|
||||||
|
create: (context) => AlertProvider(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
@@ -54,9 +101,8 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
print("test");
|
|
||||||
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,
|
||||||
@@ -91,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(
|
||||||
@@ -106,6 +154,40 @@ class MyApp extends StatelessWidget {
|
|||||||
actionCode: args['actionCode'] as String,
|
actionCode: args['actionCode'] as String,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/equipment_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: EquipmentManagementPage()),
|
||||||
|
'/container_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: ContainerManagementPage()),
|
||||||
|
'/maintenance_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "manage_maintenances",
|
||||||
|
child: MaintenanceManagementPage()),
|
||||||
|
'/container_form': (context) {
|
||||||
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
|
return AuthGuard(
|
||||||
|
requiredPermission: "manage_equipment",
|
||||||
|
child: ContainerFormPage(
|
||||||
|
container: args as ContainerModel?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'/container_detail': (context) {
|
||||||
|
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
||||||
|
return AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: ContainerDetailPage(container: container),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'/event_preparation': (context) {
|
||||||
|
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||||
|
final event = args['event'] as EventModel;
|
||||||
|
return AuthGuard(
|
||||||
|
child: EventPreparationPage(
|
||||||
|
initialEvent: event,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,31 +204,87 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Attendre la fin du premier build avant de naviguer
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_autoLogin();
|
_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) {
|
||||||
|
// 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');
|
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');
|
||||||
}
|
}
|
||||||
@@ -155,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
144
em2rp/lib/mixins/selection_mode_mixin.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Mixin réutilisable pour gérer le mode sélection multiple
|
||||||
|
/// Utilisable dans equipment_management_page, container_management_page, etc.
|
||||||
|
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
|
||||||
|
// État du mode sélection
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
bool get isSelectionMode => _isSelectionMode;
|
||||||
|
Set<String> get selectedIds => _selectedIds;
|
||||||
|
int get selectedCount => _selectedIds.length;
|
||||||
|
bool get hasSelection => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
/// Active/désactive le mode sélection
|
||||||
|
void toggleSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = !_isSelectionMode;
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
_selectedIds.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active le mode sélection
|
||||||
|
void enableSelectionMode() {
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode sélection et efface la sélection
|
||||||
|
void disableSelectionMode() {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle la sélection d'un item
|
||||||
|
void toggleItemSelection(String id) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(id)) {
|
||||||
|
_selectedIds.remove(id);
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne un item
|
||||||
|
void selectItem(String id) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.add(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désélectionne un item
|
||||||
|
void deselectItem(String id) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.remove(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un item est sélectionné
|
||||||
|
bool isItemSelected(String id) {
|
||||||
|
return _selectedIds.contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne tous les items
|
||||||
|
void selectAll(List<String> ids) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Efface la sélection
|
||||||
|
void clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne/désélectionne tous les items
|
||||||
|
void toggleSelectAll(List<String> ids) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.length == ids.length) {
|
||||||
|
// Tout est sélectionné, on désélectionne tout
|
||||||
|
_selectedIds.clear();
|
||||||
|
} else {
|
||||||
|
// Sélectionner tout
|
||||||
|
_selectedIds.addAll(ids);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour afficher le nombre d'éléments sélectionnés
|
||||||
|
Widget buildSelectionCounter({
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
String? customText,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AppBar pour le mode sélection
|
||||||
|
PreferredSizeWidget buildSelectionAppBar({
|
||||||
|
required String title,
|
||||||
|
required List<Widget> actions,
|
||||||
|
Color? backgroundColor,
|
||||||
|
}) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: disableSelectionMode,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
273
em2rp/lib/models/alert_model.dart
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Type d'alerte
|
||||||
|
enum AlertType {
|
||||||
|
lowStock, // Stock faible
|
||||||
|
maintenanceDue, // Maintenance à venir
|
||||||
|
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) {
|
||||||
|
switch (type) {
|
||||||
|
case AlertType.lowStock:
|
||||||
|
return 'LOW_STOCK';
|
||||||
|
case AlertType.maintenanceDue:
|
||||||
|
return 'MAINTENANCE_DUE';
|
||||||
|
case AlertType.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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertType alertTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'LOW_STOCK':
|
||||||
|
return AlertType.lowStock;
|
||||||
|
case 'MAINTENANCE_DUE':
|
||||||
|
return AlertType.maintenanceDue;
|
||||||
|
case '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:
|
||||||
|
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 {
|
||||||
|
final String id; // ID généré automatiquement
|
||||||
|
final AlertType type; // Type d'alerte
|
||||||
|
final AlertSeverity severity; // Gravité de l'alerte
|
||||||
|
final String message; // Message de l'alerte
|
||||||
|
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||||
|
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({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
this.severity = AlertSeverity.info,
|
||||||
|
required this.message,
|
||||||
|
this.assignedToUserIds = const [],
|
||||||
|
this.eventId,
|
||||||
|
this.equipmentId,
|
||||||
|
this.createdByUserId,
|
||||||
|
required this.createdAt,
|
||||||
|
this.dueDate,
|
||||||
|
this.actionUrl,
|
||||||
|
this.isRead = false,
|
||||||
|
this.isResolved = false,
|
||||||
|
this.resolution,
|
||||||
|
this.resolvedAt,
|
||||||
|
this.resolvedByUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
id: id,
|
||||||
|
type: alertTypeFromString(map['type']),
|
||||||
|
severity: alertSeverityFromString(map['severity']),
|
||||||
|
message: map['message'] ?? '',
|
||||||
|
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||||
|
eventId: map['eventId'],
|
||||||
|
equipmentId: map['equipmentId'],
|
||||||
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
|
createdAt: _parseDate(map['createdAt']),
|
||||||
|
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||||
|
actionUrl: map['actionUrl'],
|
||||||
|
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() {
|
||||||
|
return {
|
||||||
|
'type': alertTypeToString(type),
|
||||||
|
'severity': alertSeverityToString(severity),
|
||||||
|
'message': message,
|
||||||
|
'assignedToUserIds': assignedToUserIds,
|
||||||
|
if (eventId != null) 'eventId': eventId,
|
||||||
|
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||||
|
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||||
|
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||||
|
'isRead': isRead,
|
||||||
|
'isResolved': isResolved,
|
||||||
|
if (resolution != null) 'resolution': resolution,
|
||||||
|
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||||
|
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertModel copyWith({
|
||||||
|
String? id,
|
||||||
|
AlertType? type,
|
||||||
|
AlertSeverity? severity,
|
||||||
|
String? message,
|
||||||
|
List<String>? assignedToUserIds,
|
||||||
|
String? eventId,
|
||||||
|
String? equipmentId,
|
||||||
|
String? createdByUserId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? actionUrl,
|
||||||
|
bool? isRead,
|
||||||
|
bool? isResolved,
|
||||||
|
String? resolution,
|
||||||
|
DateTime? resolvedAt,
|
||||||
|
String? resolvedByUserId,
|
||||||
|
}) {
|
||||||
|
return AlertModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
severity: severity ?? this.severity,
|
||||||
|
message: message ?? this.message,
|
||||||
|
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
actionUrl: actionUrl ?? this.actionUrl,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
382
em2rp/lib/models/container_model.dart
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Type de container
|
||||||
|
enum ContainerType {
|
||||||
|
flightCase, // Flight case
|
||||||
|
pelicase, // Pelicase
|
||||||
|
bag, // Sac
|
||||||
|
openCrate, // Caisse ouverte
|
||||||
|
toolbox, // Boîte à outils
|
||||||
|
}
|
||||||
|
|
||||||
|
String containerTypeToString(ContainerType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'FLIGHT_CASE';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'PELICASE';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'BAG';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'OPEN_CRATE';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'TOOLBOX';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerType containerTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'FLIGHT_CASE':
|
||||||
|
return ContainerType.flightCase;
|
||||||
|
case 'PELICASE':
|
||||||
|
return ContainerType.pelicase;
|
||||||
|
case 'BAG':
|
||||||
|
return ContainerType.bag;
|
||||||
|
case 'OPEN_CRATE':
|
||||||
|
return ContainerType.openCrate;
|
||||||
|
case 'TOOLBOX':
|
||||||
|
return ContainerType.toolbox;
|
||||||
|
default:
|
||||||
|
return ContainerType.flightCase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String containerTypeLabel(ContainerType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'Flight Case';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'Pelicase';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'Sac';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'Caisse Ouverte';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'Boîte à Outils';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extensions pour centraliser les informations d'affichage
|
||||||
|
extension ContainerTypeExtension on ContainerType {
|
||||||
|
/// Retourne le label français du type de container
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'Flight Case';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'Pelicase';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'Sac';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'Caisse Ouverte';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'Boîte à Outils';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'icône Material du type de container
|
||||||
|
IconData get iconData {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return Icons.work;
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return Icons.work_outline;
|
||||||
|
case ContainerType.bag:
|
||||||
|
return Icons.shopping_bag;
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return Icons.inventory_2;
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return Icons.home_repair_service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||||
|
String? get customIconPath {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'assets/icons/flight-case.svg';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si une icône personnalisée est disponible
|
||||||
|
bool get hasCustomIcon => customIconPath != null;
|
||||||
|
|
||||||
|
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||||
|
Widget getIcon({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
// Détection automatique du format (SVG ou PNG)
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version pour CircleAvatar et contextes similaires
|
||||||
|
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle de container/boîte pour le matériel
|
||||||
|
class ContainerModel {
|
||||||
|
final String id; // Identifiant unique (généré comme pour équipement)
|
||||||
|
final String name; // Nom du container
|
||||||
|
final ContainerType type; // Type de container
|
||||||
|
final EquipmentStatus status; // Statut actuel (même que équipement)
|
||||||
|
|
||||||
|
// Caractéristiques physiques
|
||||||
|
final double? weight; // Poids à vide (kg)
|
||||||
|
final double? length; // Longueur (cm)
|
||||||
|
final double? width; // Largeur (cm)
|
||||||
|
final double? height; // Hauteur (cm)
|
||||||
|
|
||||||
|
// Contenu
|
||||||
|
final List<String> equipmentIds; // IDs des équipements contenus
|
||||||
|
|
||||||
|
// Événement
|
||||||
|
final String? eventId; // ID de l'événement actuel (si en prestation)
|
||||||
|
|
||||||
|
// Métadonnées
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
// Historique simple (optionnel)
|
||||||
|
final List<ContainerHistoryEntry> history; // Historique des modifications
|
||||||
|
|
||||||
|
ContainerModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.status = EquipmentStatus.available,
|
||||||
|
this.weight,
|
||||||
|
this.length,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.equipmentIds = const [],
|
||||||
|
this.eventId,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.history = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Vérifier si le container est vide
|
||||||
|
bool get isEmpty => equipmentIds.isEmpty;
|
||||||
|
|
||||||
|
/// Nombre d'équipements dans le container
|
||||||
|
int get itemCount => equipmentIds.length;
|
||||||
|
|
||||||
|
/// Calculer le volume (m³)
|
||||||
|
double? get volume {
|
||||||
|
if (length == null || width == null || height == null) return null;
|
||||||
|
return (length! * width! * height!) / 1000000; // cm³ to m³
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculer le poids total (poids vide + équipements)
|
||||||
|
/// Nécessite la liste des équipements
|
||||||
|
double calculateTotalWeight(List<EquipmentModel> equipment) {
|
||||||
|
double total = weight ?? 0.0;
|
||||||
|
for (final eq in equipment) {
|
||||||
|
if (equipmentIds.contains(eq.id) && eq.weight != null) {
|
||||||
|
total += eq.weight!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory depuis Firestore
|
||||||
|
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<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
final List<dynamic> historyRaw = map['history'] ?? [];
|
||||||
|
final List<ContainerHistoryEntry> history = historyRaw
|
||||||
|
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ContainerModel(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
type: containerTypeFromString(map['type']),
|
||||||
|
status: equipmentStatusFromString(map['status']),
|
||||||
|
weight: map['weight']?.toDouble(),
|
||||||
|
length: map['length']?.toDouble(),
|
||||||
|
width: map['width']?.toDouble(),
|
||||||
|
height: map['height']?.toDouble(),
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
eventId: map['eventId'],
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
history: history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertir en Map pour Firestore
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'type': containerTypeToString(type),
|
||||||
|
'status': equipmentStatusToString(status),
|
||||||
|
'weight': weight,
|
||||||
|
'length': length,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
'eventId': eventId,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
'history': history.map((e) => e.toMap()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copier avec modifications
|
||||||
|
ContainerModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
ContainerType? type,
|
||||||
|
EquipmentStatus? status,
|
||||||
|
double? weight,
|
||||||
|
double? length,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
List<String>? equipmentIds,
|
||||||
|
String? eventId,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
List<ContainerHistoryEntry>? history,
|
||||||
|
}) {
|
||||||
|
return ContainerModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
status: status ?? this.status,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
|
length: length ?? this.length,
|
||||||
|
width: width ?? this.width,
|
||||||
|
height: height ?? this.height,
|
||||||
|
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
history: history ?? this.history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entrée d'historique pour un container
|
||||||
|
class ContainerHistoryEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String action; // 'added', 'removed', 'status_change', etc.
|
||||||
|
final String? equipmentId; // ID de l'équipement concerné (si applicable)
|
||||||
|
final String? previousValue; // Valeur précédente
|
||||||
|
final String? newValue; // Nouvelle valeur
|
||||||
|
final String? userId; // ID de l'utilisateur ayant fait la modification
|
||||||
|
|
||||||
|
ContainerHistoryEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.action,
|
||||||
|
this.equipmentId,
|
||||||
|
this.previousValue,
|
||||||
|
this.newValue,
|
||||||
|
this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
timestamp: _parseDate(map['timestamp']),
|
||||||
|
action: map['action'] ?? '',
|
||||||
|
equipmentId: map['equipmentId'],
|
||||||
|
previousValue: map['previousValue'],
|
||||||
|
newValue: map['newValue'],
|
||||||
|
userId: map['userId'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'timestamp': Timestamp.fromDate(timestamp),
|
||||||
|
'action': action,
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'previousValue': previousValue,
|
||||||
|
'newValue': newValue,
|
||||||
|
'userId': userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
528
em2rp/lib/models/equipment_model.dart
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
enum EquipmentStatus {
|
||||||
|
available, // Disponible
|
||||||
|
inUse, // En prestation
|
||||||
|
rented, // Loué
|
||||||
|
lost, // Perdu
|
||||||
|
outOfService, // HS
|
||||||
|
maintenance, // En maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
String equipmentStatusToString(EquipmentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return 'AVAILABLE';
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return 'IN_USE';
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return 'RENTED';
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return 'LOST';
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return 'OUT_OF_SERVICE';
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return 'MAINTENANCE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentStatus equipmentStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'AVAILABLE':
|
||||||
|
return EquipmentStatus.available;
|
||||||
|
case 'IN_USE':
|
||||||
|
return EquipmentStatus.inUse;
|
||||||
|
case 'RENTED':
|
||||||
|
return EquipmentStatus.rented;
|
||||||
|
case 'LOST':
|
||||||
|
return EquipmentStatus.lost;
|
||||||
|
case 'OUT_OF_SERVICE':
|
||||||
|
return EquipmentStatus.outOfService;
|
||||||
|
case 'MAINTENANCE':
|
||||||
|
return EquipmentStatus.maintenance;
|
||||||
|
default:
|
||||||
|
return EquipmentStatus.available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EquipmentCategory {
|
||||||
|
lighting, // Lumière
|
||||||
|
sound, // Son
|
||||||
|
video, // Vidéo
|
||||||
|
effect, // Effets spéciaux
|
||||||
|
structure, // Structure
|
||||||
|
consumable, // Consommable
|
||||||
|
cable, // Câble
|
||||||
|
vehicle, // Véhicule
|
||||||
|
backline, // Régie / Backline
|
||||||
|
other // Autre
|
||||||
|
}
|
||||||
|
|
||||||
|
String equipmentCategoryToString(EquipmentCategory category) {
|
||||||
|
switch (category) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return 'LIGHTING';
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return 'SOUND';
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return 'VIDEO';
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'STRUCTURE';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'CONSUMABLE';
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return 'CABLE';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'VEHICLE';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'BACKLINE';
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return 'OTHER';
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return 'EFFECT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentCategory equipmentCategoryFromString(String? category) {
|
||||||
|
switch (category) {
|
||||||
|
case 'LIGHTING':
|
||||||
|
return EquipmentCategory.lighting;
|
||||||
|
case 'SOUND':
|
||||||
|
return EquipmentCategory.sound;
|
||||||
|
case 'VIDEO':
|
||||||
|
return EquipmentCategory.video;
|
||||||
|
case 'STRUCTURE':
|
||||||
|
return EquipmentCategory.structure;
|
||||||
|
case 'CONSUMABLE':
|
||||||
|
return EquipmentCategory.consumable;
|
||||||
|
case 'CABLE':
|
||||||
|
return EquipmentCategory.cable;
|
||||||
|
case 'VEHICLE':
|
||||||
|
return EquipmentCategory.vehicle;
|
||||||
|
case 'BACKLINE':
|
||||||
|
return EquipmentCategory.backline;
|
||||||
|
case 'EFFECT':
|
||||||
|
return EquipmentCategory.effect;
|
||||||
|
case 'OTHER':
|
||||||
|
default:
|
||||||
|
return EquipmentCategory.other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extensions pour centraliser les informations d'affichage
|
||||||
|
extension EquipmentCategoryExtension on EquipmentCategory {
|
||||||
|
/// Retourne le label français de la catégorie
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return 'Lumière';
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return 'Son';
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return 'Vidéo';
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return 'Effets';
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'Structure';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'Consommable';
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return 'Autre';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'icône Material de la catégorie
|
||||||
|
IconData get iconData {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return Icons.light_mode;
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return Icons.volume_up;
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return Icons.videocam;
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return Icons.auto_awesome;
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return Icons.construction;
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return Icons.inventory_2;
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return Icons.more_horiz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la couleur associée à la catégorie
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return Colors.yellow.shade700;
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return Colors.purple;
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return Colors.blue;
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return Colors.pink;
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return Colors.brown;
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return Colors.orange;
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return Colors.grey;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Colors.teal;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Colors.indigo;
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return Colors.blueGrey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||||
|
String? get customIconPath {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'assets/icons/truss.svg';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'assets/icons/tape.svg';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si une icône personnalisée est disponible
|
||||||
|
bool get hasCustomIcon => customIconPath != null;
|
||||||
|
|
||||||
|
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||||
|
Widget getIcon({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
// Détection automatique du format (SVG ou PNG)
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version pour CircleAvatar et contextes similaires (sans ColorFilter si Material Icon)
|
||||||
|
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension EquipmentStatusExtension on EquipmentStatus {
|
||||||
|
/// Retourne le label français du statut
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return 'Disponible';
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return 'En prestation';
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return 'Loué';
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return 'Perdu';
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return 'HS';
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return 'Maintenance';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la couleur associée au statut
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return Colors.green;
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return Colors.blue;
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return Colors.orange;
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return Colors.red;
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return Colors.red.shade900;
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return Colors.amber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EquipmentModel {
|
||||||
|
final String id; // Identifiant unique (clé)
|
||||||
|
final String name; // Nom de l'équipement
|
||||||
|
final String? brand; // Marque (indexé)
|
||||||
|
final String? model; // Modèle (indexé)
|
||||||
|
final EquipmentCategory category; // Catégorie
|
||||||
|
final String? subCategory; // Sous-catégorie (indexé par catégorie)
|
||||||
|
final EquipmentStatus status; // Statut actuel
|
||||||
|
|
||||||
|
// Prix (visible uniquement avec manage_equipment)
|
||||||
|
final double? purchasePrice; // Prix d'achat
|
||||||
|
final double? rentalPrice; // Prix de location
|
||||||
|
|
||||||
|
// Quantité (pour consommables/câbles)
|
||||||
|
final int? totalQuantity; // Quantité totale
|
||||||
|
final int? availableQuantity; // Quantité disponible
|
||||||
|
final int? criticalThreshold; // Seuil critique pour alerte
|
||||||
|
|
||||||
|
|
||||||
|
// Caractéristiques physiques
|
||||||
|
final double? weight; // Poids (kg)
|
||||||
|
final double? length; // Longueur (cm)
|
||||||
|
final double? width; // Largeur (cm)
|
||||||
|
final double? height; // Hauteur (cm)
|
||||||
|
|
||||||
|
// Dates & maintenance
|
||||||
|
final DateTime? purchaseDate; // Date d'achat
|
||||||
|
final DateTime? lastMaintenanceDate; // Dernière maintenance
|
||||||
|
final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue
|
||||||
|
|
||||||
|
// Maintenances (références)
|
||||||
|
final List<String> maintenanceIds; // IDs des opérations de maintenance
|
||||||
|
|
||||||
|
// Image
|
||||||
|
final String? imageUrl; // URL de l'image (Storage /materiel)
|
||||||
|
|
||||||
|
// Métadonnées
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
EquipmentModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.brand,
|
||||||
|
this.model,
|
||||||
|
required this.category,
|
||||||
|
this.subCategory,
|
||||||
|
this.status = EquipmentStatus.available,
|
||||||
|
this.purchasePrice,
|
||||||
|
this.rentalPrice,
|
||||||
|
this.totalQuantity,
|
||||||
|
this.availableQuantity,
|
||||||
|
this.criticalThreshold,
|
||||||
|
this.weight,
|
||||||
|
this.length,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.purchaseDate,
|
||||||
|
this.lastMaintenanceDate,
|
||||||
|
this.nextMaintenanceDate,
|
||||||
|
this.maintenanceIds = const [],
|
||||||
|
this.imageUrl,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EquipmentModel.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 des listes
|
||||||
|
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||||
|
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
return EquipmentModel(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
brand: map['brand'],
|
||||||
|
model: map['model'],
|
||||||
|
category: equipmentCategoryFromString(map['category']),
|
||||||
|
subCategory: map['subCategory'],
|
||||||
|
status: equipmentStatusFromString(map['status']),
|
||||||
|
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||||
|
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||||
|
totalQuantity: map['totalQuantity']?.toInt(),
|
||||||
|
availableQuantity: map['availableQuantity']?.toInt(),
|
||||||
|
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||||
|
weight: map['weight']?.toDouble(),
|
||||||
|
length: map['length']?.toDouble(),
|
||||||
|
width: map['width']?.toDouble(),
|
||||||
|
height: map['height']?.toDouble(),
|
||||||
|
purchaseDate: _parseDate(map['purchaseDate']),
|
||||||
|
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
||||||
|
maintenanceIds: maintenanceIds,
|
||||||
|
imageUrl: map['imageUrl'],
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'brand': brand,
|
||||||
|
'model': model,
|
||||||
|
'category': equipmentCategoryToString(category),
|
||||||
|
'subCategory': subCategory,
|
||||||
|
'status': equipmentStatusToString(status),
|
||||||
|
'purchasePrice': purchasePrice,
|
||||||
|
'rentalPrice': rentalPrice,
|
||||||
|
'totalQuantity': totalQuantity,
|
||||||
|
'availableQuantity': availableQuantity,
|
||||||
|
'criticalThreshold': criticalThreshold,
|
||||||
|
'weight': weight,
|
||||||
|
'length': length,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
|
||||||
|
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
|
||||||
|
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
|
||||||
|
'maintenanceIds': maintenanceIds,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? brand,
|
||||||
|
String? name,
|
||||||
|
String? model,
|
||||||
|
EquipmentCategory? category,
|
||||||
|
String? subCategory,
|
||||||
|
EquipmentStatus? status,
|
||||||
|
double? purchasePrice,
|
||||||
|
double? rentalPrice,
|
||||||
|
int? totalQuantity,
|
||||||
|
int? availableQuantity,
|
||||||
|
int? criticalThreshold,
|
||||||
|
double? weight,
|
||||||
|
double? length,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
DateTime? purchaseDate,
|
||||||
|
DateTime? lastMaintenanceDate,
|
||||||
|
DateTime? nextMaintenanceDate,
|
||||||
|
List<String>? maintenanceIds,
|
||||||
|
String? imageUrl,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return EquipmentModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
brand: brand ?? this.brand,
|
||||||
|
name: name ?? this.name,
|
||||||
|
model: model ?? this.model,
|
||||||
|
category: category ?? this.category,
|
||||||
|
subCategory: subCategory ?? this.subCategory,
|
||||||
|
status: status ?? this.status,
|
||||||
|
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||||
|
rentalPrice: rentalPrice ?? this.rentalPrice,
|
||||||
|
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||||
|
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||||
|
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
|
length: length ?? this.length,
|
||||||
|
width: width ?? this.width,
|
||||||
|
height: height ?? this.height,
|
||||||
|
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
|
||||||
|
purchaseDate: purchaseDate ?? this.purchaseDate,
|
||||||
|
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
|
||||||
|
maintenanceIds: maintenanceIds ?? this.maintenanceIds,
|
||||||
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si c'est un consommable/câble avec quantité
|
||||||
|
bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable;
|
||||||
|
|
||||||
|
// Helper pour vérifier si le stock est critique
|
||||||
|
bool get isCriticalStock {
|
||||||
|
if (!hasQuantity || criticalThreshold == null || availableQuantity == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return availableQuantity! <= criticalThreshold!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est à venir
|
||||||
|
bool get isMaintenanceDue {
|
||||||
|
if (nextMaintenanceDate == null) return false;
|
||||||
|
return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
|
|
||||||
enum EventStatus {
|
enum EventStatus {
|
||||||
confirmed,
|
confirmed,
|
||||||
@@ -14,7 +13,6 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +29,258 @@ EventStatus eventStatusFromString(String? status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PreparationStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String preparationStatusToString(PreparationStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PreparationStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case PreparationStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case PreparationStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case PreparationStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PreparationStatus preparationStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return PreparationStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return PreparationStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return PreparationStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return PreparationStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return PreparationStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String returnStatusToString(ReturnStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case ReturnStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case ReturnStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case ReturnStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case ReturnStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReturnStatus returnStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return ReturnStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return ReturnStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return ReturnStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return ReturnStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return ReturnStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventEquipment {
|
||||||
|
final String equipmentId; // ID de l'équipement
|
||||||
|
final int quantity; // Quantité initiale assignée
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
required this.equipmentId,
|
||||||
|
this.quantity = 1,
|
||||||
|
this.isPrepared = false,
|
||||||
|
this.isLoaded = false,
|
||||||
|
this.isUnloaded = false,
|
||||||
|
this.isReturned = false,
|
||||||
|
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) {
|
||||||
|
return EventEquipment(
|
||||||
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
|
quantity: map['quantity'] ?? 1,
|
||||||
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
|
isReturned: map['isReturned'] ?? false,
|
||||||
|
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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'isPrepared': isPrepared,
|
||||||
|
'isLoaded': isLoaded,
|
||||||
|
'isUnloaded': isUnloaded,
|
||||||
|
'isReturned': isReturned,
|
||||||
|
'isMissingAtPreparation': isMissingAtPreparation,
|
||||||
|
'isMissingAtLoading': isMissingAtLoading,
|
||||||
|
'isMissingAtUnloading': isMissingAtUnloading,
|
||||||
|
'isMissingAtReturn': isMissingAtReturn,
|
||||||
|
'quantityAtPreparation': quantityAtPreparation,
|
||||||
|
'quantityAtLoading': quantityAtLoading,
|
||||||
|
'quantityAtUnloading': quantityAtUnloading,
|
||||||
|
'quantityAtReturn': quantityAtReturn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
EventEquipment copyWith({
|
||||||
|
String? equipmentId,
|
||||||
|
int? quantity,
|
||||||
|
bool? isPrepared,
|
||||||
|
bool? isLoaded,
|
||||||
|
bool? isUnloaded,
|
||||||
|
bool? isReturned,
|
||||||
|
bool? isMissingAtPreparation,
|
||||||
|
bool? isMissingAtLoading,
|
||||||
|
bool? isMissingAtUnloading,
|
||||||
|
bool? isMissingAtReturn,
|
||||||
|
int? quantityAtPreparation,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
int? quantityAtUnloading,
|
||||||
|
int? quantityAtReturn,
|
||||||
|
}) {
|
||||||
|
return EventEquipment(
|
||||||
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
|
isReturned: isReturned ?? this.isReturned,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class EventModel {
|
class EventModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -41,15 +291,29 @@ class EventModel {
|
|||||||
final int installationTime;
|
final int installationTime;
|
||||||
final int disassemblyTime;
|
final int disassemblyTime;
|
||||||
final String eventTypeId;
|
final String eventTypeId;
|
||||||
|
final DocumentReference? eventTypeRef;
|
||||||
final String customerId;
|
final String customerId;
|
||||||
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;
|
||||||
|
|
||||||
|
// Champs de contact
|
||||||
|
final int? jauge;
|
||||||
|
final String? contactEmail;
|
||||||
|
final String? contactPhone;
|
||||||
|
|
||||||
|
// Nouveaux champs pour la gestion du matériel
|
||||||
|
final List<EventEquipment> assignedEquipment;
|
||||||
|
final List<String> assignedContainers; // IDs des conteneurs assignés
|
||||||
|
final PreparationStatus? preparationStatus;
|
||||||
|
final LoadingStatus? loadingStatus;
|
||||||
|
final UnloadingStatus? unloadingStatus;
|
||||||
|
final ReturnStatus? returnStatus;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -60,6 +324,7 @@ class EventModel {
|
|||||||
required this.installationTime,
|
required this.installationTime,
|
||||||
required this.disassemblyTime,
|
required this.disassemblyTime,
|
||||||
required this.eventTypeId,
|
required this.eventTypeId,
|
||||||
|
this.eventTypeRef,
|
||||||
required this.customerId,
|
required this.customerId,
|
||||||
required this.address,
|
required this.address,
|
||||||
required this.latitude,
|
required this.latitude,
|
||||||
@@ -68,62 +333,197 @@ class EventModel {
|
|||||||
required this.documents,
|
required this.documents,
|
||||||
this.options = const [],
|
this.options = const [],
|
||||||
this.status = EventStatus.waitingForApproval,
|
this.status = EventStatus.waitingForApproval,
|
||||||
|
this.jauge,
|
||||||
|
this.contactEmail,
|
||||||
|
this.contactPhone,
|
||||||
|
this.assignedEquipment = const [],
|
||||||
|
this.assignedContainers = const [],
|
||||||
|
this.preparationStatus,
|
||||||
|
this.loadingStatus,
|
||||||
|
this.unloadingStatus,
|
||||||
|
this.returnStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
try {
|
||||||
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
|
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
|
||||||
|
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||||
|
final List<dynamic> safeWorkforce = [];
|
||||||
|
|
||||||
|
for (var ref in workforceRefs) {
|
||||||
|
if (ref is DocumentReference) {
|
||||||
|
safeWorkforce.add(ref);
|
||||||
|
} else if (ref is String) {
|
||||||
|
// Accepter directement les UIDs (envoyés par le backend)
|
||||||
|
safeWorkforce.add(ref);
|
||||||
|
} else {
|
||||||
|
print('Warning: Invalid workforce reference in event $id: $ref');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
|
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
||||||
|
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
|
// Gestion sécurisée des documents
|
||||||
final docsRaw = map['documents'] ?? [];
|
final docsRaw = map['documents'] ?? [];
|
||||||
final docs = docsRaw is List
|
final List<Map<String, String>> docs = [];
|
||||||
? docsRaw.map<Map<String, String>>((e) {
|
|
||||||
|
if (docsRaw is List) {
|
||||||
|
for (var e in docsRaw) {
|
||||||
|
try {
|
||||||
if (e is Map) {
|
if (e is Map) {
|
||||||
return Map<String, String>.from(e as Map);
|
docs.add(Map<String, String>.from(e));
|
||||||
} else if (e is String) {
|
} else if (e is String) {
|
||||||
final fileName = Uri.decodeComponent(
|
final fileName = Uri.decodeComponent(
|
||||||
e.split('/').last.split('?').first,
|
e.split('/').last.split('?').first,
|
||||||
);
|
);
|
||||||
return {'name': fileName, 'url': e};
|
docs.add({'name': fileName, 'url': e});
|
||||||
} else {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}).toList()
|
} catch (docError) {
|
||||||
: <Map<String, String>>[];
|
print('Warning: Failed to parse document in event $id: $docError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée des options
|
||||||
final optionsRaw = map['options'] ?? [];
|
final optionsRaw = map['options'] ?? [];
|
||||||
final options = optionsRaw is List
|
final List<Map<String, dynamic>> options = [];
|
||||||
? optionsRaw.map<Map<String, dynamic>>((e) {
|
|
||||||
|
if (optionsRaw is List) {
|
||||||
|
for (var e in optionsRaw) {
|
||||||
|
try {
|
||||||
if (e is Map) {
|
if (e is Map) {
|
||||||
return Map<String, dynamic>.from(e as Map);
|
options.add(Map<String, dynamic>.from(e));
|
||||||
} else {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}).toList()
|
} catch (optionError) {
|
||||||
: <Map<String, dynamic>>[];
|
print('Warning: Failed to parse option in event $id: $optionError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée de l'EventType
|
||||||
|
String eventTypeId = '';
|
||||||
|
DocumentReference? eventTypeRef;
|
||||||
|
|
||||||
|
if (map['EventType'] is DocumentReference) {
|
||||||
|
eventTypeRef = map['EventType'] as DocumentReference;
|
||||||
|
eventTypeId = eventTypeRef.id;
|
||||||
|
} else if (map['EventType'] is 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
|
||||||
|
String customerId = '';
|
||||||
|
if (map['customer'] is DocumentReference) {
|
||||||
|
customerId = (map['customer'] as DocumentReference).id;
|
||||||
|
} else if (map['customer'] is 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
|
||||||
|
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
|
||||||
|
final List<EventEquipment> assignedEquipment = [];
|
||||||
|
|
||||||
|
if (assignedEquipmentRaw is List) {
|
||||||
|
for (var e in assignedEquipmentRaw) {
|
||||||
|
try {
|
||||||
|
if (e is Map) {
|
||||||
|
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
|
||||||
|
}
|
||||||
|
} catch (equipmentError) {
|
||||||
|
print('Warning: Failed to parse equipment in event $id: $equipmentError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des conteneurs assignés
|
||||||
|
final assignedContainersRaw = map['assignedContainers'] ?? [];
|
||||||
|
final List<String> assignedContainers = [];
|
||||||
|
|
||||||
|
if (assignedContainersRaw is List) {
|
||||||
|
for (var e in assignedContainersRaw) {
|
||||||
|
if (e is String) {
|
||||||
|
assignedContainers.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return EventModel(
|
return EventModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['Name'] ?? '',
|
name: (map['Name'] ?? '').toString().trim(),
|
||||||
description: map['Description'] ?? '',
|
description: (map['Description'] ?? '').toString(),
|
||||||
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
startDateTime: startDate,
|
||||||
endDateTime: endTimestamp?.toDate() ??
|
endDateTime: endDate,
|
||||||
DateTime.now().add(const Duration(hours: 1)),
|
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
|
||||||
basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(),
|
installationTime: _parseInt(map['InstallationTime'] ?? 0),
|
||||||
installationTime: map['InstallationTime'] ?? 0,
|
assignedContainers: assignedContainers,
|
||||||
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
|
||||||
eventTypeId: map['EventType'] is DocumentReference
|
eventTypeId: eventTypeId,
|
||||||
? (map['EventType'] as DocumentReference).id
|
eventTypeRef: eventTypeRef,
|
||||||
: '',
|
customerId: customerId,
|
||||||
customerId: map['customer'] is DocumentReference
|
address: (map['Address'] ?? '').toString(),
|
||||||
? (map['customer'] as DocumentReference).id
|
latitude: _parseDouble(map['Latitude'] ?? 0.0),
|
||||||
: '',
|
longitude: _parseDouble(map['Longitude'] ?? 0.0),
|
||||||
address: map['Address'] ?? '',
|
workforce: safeWorkforce,
|
||||||
latitude: (map['Latitude'] ?? 0.0).toDouble(),
|
|
||||||
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
|
||||||
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
|
||||||
documents: docs,
|
documents: docs,
|
||||||
options: options,
|
options: options,
|
||||||
status: eventStatusFromString(map['status'] as String?),
|
status: eventStatusFromString(map['status'] as String?),
|
||||||
|
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
|
||||||
|
contactEmail: map['contactEmail']?.toString(),
|
||||||
|
contactPhone: map['contactPhone']?.toString(),
|
||||||
|
assignedEquipment: assignedEquipment,
|
||||||
|
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
|
||||||
|
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
|
||||||
|
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
|
||||||
|
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error parsing event $id: $e');
|
||||||
|
print('Event data: $map');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes utilitaires pour le parsing sécurisé
|
||||||
|
static double _parseDouble(dynamic value) {
|
||||||
|
if (value is double) return value;
|
||||||
|
if (value is int) return value.toDouble();
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = double.tryParse(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is double) return value.toInt();
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = int.tryParse(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
@@ -135,8 +535,10 @@ class EventModel {
|
|||||||
'BasePrice': basePrice,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId,
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
'customer': customerId,
|
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||||
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
'customer': customerId.isNotEmpty ? customerId : null,
|
||||||
'Address': address,
|
'Address': address,
|
||||||
'Position': GeoPoint(latitude, longitude),
|
'Position': GeoPoint(latitude, longitude),
|
||||||
'Latitude': latitude,
|
'Latitude': latitude,
|
||||||
@@ -145,6 +547,75 @@ class EventModel {
|
|||||||
'documents': documents,
|
'documents': documents,
|
||||||
'options': options,
|
'options': options,
|
||||||
'status': eventStatusToString(status),
|
'status': eventStatusToString(status),
|
||||||
|
'jauge': jauge,
|
||||||
|
'contactEmail': contactEmail,
|
||||||
|
'contactPhone': contactPhone,
|
||||||
|
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
||||||
|
'assignedContainers': assignedContainers,
|
||||||
|
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
||||||
|
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
|
||||||
|
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
em2rp/lib/models/event_type_model.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class EventTypeModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final double defaultPrice;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
EventTypeModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.defaultPrice,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
||||||
|
createdAt: parseCreatedAt(map['createdAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'defaultPrice': defaultPrice,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
146
em2rp/lib/models/maintenance_model.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
enum MaintenanceType {
|
||||||
|
preventive, // Préventive
|
||||||
|
corrective, // Corrective
|
||||||
|
inspection // Inspection
|
||||||
|
}
|
||||||
|
|
||||||
|
String maintenanceTypeToString(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.preventive:
|
||||||
|
return 'PREVENTIVE';
|
||||||
|
case MaintenanceType.corrective:
|
||||||
|
return 'CORRECTIVE';
|
||||||
|
case MaintenanceType.inspection:
|
||||||
|
return 'INSPECTION';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaintenanceType maintenanceTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'PREVENTIVE':
|
||||||
|
return MaintenanceType.preventive;
|
||||||
|
case 'CORRECTIVE':
|
||||||
|
return MaintenanceType.corrective;
|
||||||
|
case 'INSPECTION':
|
||||||
|
return MaintenanceType.inspection;
|
||||||
|
default:
|
||||||
|
return MaintenanceType.preventive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaintenanceModel {
|
||||||
|
final String id; // ID aléatoire
|
||||||
|
final List<String> equipmentIds; // IDs des équipements concernés (peut être multiple)
|
||||||
|
final MaintenanceType type; // Type de maintenance
|
||||||
|
final DateTime scheduledDate; // Date planifiée
|
||||||
|
final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée)
|
||||||
|
final String name; // Nom de l'opération
|
||||||
|
final String description; // Description détaillée
|
||||||
|
final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance
|
||||||
|
final double? cost; // Coût de la maintenance
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
MaintenanceModel({
|
||||||
|
required this.id,
|
||||||
|
required this.equipmentIds,
|
||||||
|
required this.type,
|
||||||
|
required this.scheduledDate,
|
||||||
|
this.completedDate,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
this.performedBy,
|
||||||
|
this.cost,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
return MaintenanceModel(
|
||||||
|
id: id,
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
type: maintenanceTypeFromString(map['type']),
|
||||||
|
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
|
completedDate: _parseDate(map['completedDate']),
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
description: map['description'] ?? '',
|
||||||
|
performedBy: map['performedBy'],
|
||||||
|
cost: map['cost']?.toDouble(),
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
'type': maintenanceTypeToString(type),
|
||||||
|
'scheduledDate': Timestamp.fromDate(scheduledDate),
|
||||||
|
'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'performedBy': performedBy,
|
||||||
|
'cost': cost,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MaintenanceModel copyWith({
|
||||||
|
String? id,
|
||||||
|
List<String>? equipmentIds,
|
||||||
|
MaintenanceType? type,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
DateTime? completedDate,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? performedBy,
|
||||||
|
double? cost,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return MaintenanceModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||||
|
type: type ?? this.type,
|
||||||
|
scheduledDate: scheduledDate ?? this.scheduledDate,
|
||||||
|
completedDate: completedDate ?? this.completedDate,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
performedBy: performedBy ?? this.performedBy,
|
||||||
|
cost: cost ?? this.cost,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est complétée
|
||||||
|
bool get isCompleted => completedDate != null;
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est en retard
|
||||||
|
bool get isOverdue {
|
||||||
|
if (isCompleted) return false;
|
||||||
|
return scheduledDate.isBefore(DateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,42 +1,48 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
|
|
||||||
class EventOption {
|
class EventOption {
|
||||||
final String id;
|
final String id;
|
||||||
|
final String code; // Nouveau champ code
|
||||||
final String name;
|
final String name;
|
||||||
final String details;
|
final String details;
|
||||||
final double valMin;
|
final double valMin;
|
||||||
final double valMax;
|
final double valMax;
|
||||||
final List<DocumentReference> eventTypes;
|
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
|
||||||
|
final bool isQuantitative; // Indique si l'option peut avoir une quantité
|
||||||
|
|
||||||
EventOption({
|
EventOption({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.code,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.details,
|
required this.details,
|
||||||
required this.valMin,
|
required this.valMin,
|
||||||
required this.valMax,
|
required this.valMax,
|
||||||
required this.eventTypes,
|
required this.eventTypes,
|
||||||
|
this.isQuantitative = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
|
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
|
||||||
return EventOption(
|
return EventOption(
|
||||||
id: id,
|
id: id,
|
||||||
|
code: map['code'] ?? id, // Utilise le code ou l'ID en fallback
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
details: map['details'] ?? '',
|
details: map['details'] ?? '',
|
||||||
valMin: (map['valMin'] ?? 0.0).toDouble(),
|
valMin: (map['valMin'] ?? 0.0).toDouble(),
|
||||||
valMax: (map['valMax'] ?? 0.0).toDouble(),
|
valMax: (map['valMax'] ?? 0.0).toDouble(),
|
||||||
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
|
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
|
||||||
.whereType<DocumentReference>()
|
.map((e) => e.toString()) // Convertit en String (supporte IDs et références)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
isQuantitative: map['isQuantitative'] ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
|
'code': code,
|
||||||
'name': name,
|
'name': name,
|
||||||
'details': details,
|
'details': details,
|
||||||
'valMin': valMin,
|
'valMin': valMin,
|
||||||
'valMax': valMax,
|
'valMax': valMax,
|
||||||
'eventTypes': eventTypes,
|
'eventTypes': eventTypes,
|
||||||
|
'isQuantitative': isQuantitative,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,3 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
|
|
||||||
class RoleModel {
|
class RoleModel {
|
||||||
final String id;
|
final String id;
|
||||||
|
|||||||
@@ -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 {
|
} else {
|
||||||
roleString = 'USER';
|
roleString = 'USER';
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cas par défaut
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
em2rp/lib/providers/alert_provider.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
class AlertProvider extends ChangeNotifier {
|
||||||
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Nombre d'alertes non lues
|
||||||
|
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
||||||
|
|
||||||
|
/// Alertes non lues uniquement
|
||||||
|
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
|
||||||
|
|
||||||
|
/// Alertes de stock critique
|
||||||
|
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
|
||||||
|
|
||||||
|
/// Alertes de maintenance
|
||||||
|
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
|
||||||
|
|
||||||
|
/// Alertes de conflit
|
||||||
|
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
||||||
|
|
||||||
|
/// Charger toutes les alertes via Cloud Function
|
||||||
|
Future<void> loadAlerts() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getAlerts', {});
|
||||||
|
final alertsData = result['alerts'] as List<dynamic>;
|
||||||
|
|
||||||
|
_alerts = alertsData.map((data) {
|
||||||
|
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 via Cloud Function
|
||||||
|
Future<void> markAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Mettre à jour localement
|
||||||
|
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) {
|
||||||
|
print('Error marking alert as read: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer une alerte via Cloud Function
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Supprimer localement
|
||||||
|
_alerts.removeWhere((a) => a.id == alertId);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting alert: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
try {
|
||||||
|
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
|
for (final alertId in readAlertIds) {
|
||||||
|
await deleteAlert(alertId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting read alerts: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
452
em2rp/lib/providers/container_provider.dart
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.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 {
|
||||||
|
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;
|
||||||
|
EquipmentStatus? _selectedStatus;
|
||||||
|
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;
|
||||||
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
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,
|
||||||
|
status: _selectedStatus,
|
||||||
|
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é
|
||||||
|
void setSelectedType(ContainerType? type) async {
|
||||||
|
if (_selectedType == type) return;
|
||||||
|
_selectedType = type;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le statut sélectionné
|
||||||
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
|
_selectedStatus = status;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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
|
||||||
|
Future<void> createContainer(ContainerModel container) async {
|
||||||
|
await _containerService.createContainer(container);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour un container
|
||||||
|
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||||
|
await _containerService.updateContainer(id, data);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer un container
|
||||||
|
Future<void> deleteContainer(String id) async {
|
||||||
|
await _containerService.deleteContainer(id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer un container par ID
|
||||||
|
Future<ContainerModel?> getContainerById(String id) async {
|
||||||
|
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
|
||||||
|
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||||
|
required String containerId,
|
||||||
|
required String equipmentId,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
final result = await _containerService.addEquipmentToContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retirer un équipement d'un container
|
||||||
|
Future<void> removeEquipmentFromContainer({
|
||||||
|
required String containerId,
|
||||||
|
required String equipmentId,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
await _containerService.removeEquipmentFromContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier la disponibilité d'un container
|
||||||
|
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||||
|
required String containerId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
return await _containerService.checkContainerAvailability(
|
||||||
|
containerId: containerId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les équipements d'un container
|
||||||
|
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||||
|
return await _containerService.getContainerEquipment(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trouver tous les containers contenant un équipement
|
||||||
|
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||||
|
return await _containerService.findContainersWithEquipment(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si un ID existe
|
||||||
|
Future<bool> checkContainerIdExists(String id) async {
|
||||||
|
return await _containerService.checkContainerIdExists(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer un ID unique pour un container
|
||||||
|
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
|
||||||
|
static String generateContainerId({
|
||||||
|
required ContainerType type,
|
||||||
|
required String name,
|
||||||
|
int? number,
|
||||||
|
}) {
|
||||||
|
// Obtenir le type en majuscules
|
||||||
|
final typeStr = containerTypeToString(type);
|
||||||
|
|
||||||
|
// Nettoyer le nom (enlever espaces, caractères spéciaux)
|
||||||
|
final cleanName = name
|
||||||
|
.replaceAll(' ', '_')
|
||||||
|
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
if (number != null) {
|
||||||
|
return 'BOX_${typeStr}_${cleanName}_#$number';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'BOX_${typeStr}_$cleanName';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assurer l'unicité d'un ID de container
|
||||||
|
static Future<String> ensureUniqueContainerId(
|
||||||
|
String baseId,
|
||||||
|
ContainerService service,
|
||||||
|
) async {
|
||||||
|
String uniqueId = baseId;
|
||||||
|
int counter = 1;
|
||||||
|
|
||||||
|
while (await service.checkContainerIdExists(uniqueId)) {
|
||||||
|
uniqueId = '${baseId}_$counter';
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
533
em2rp/lib/providers/equipment_provider.dart
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:em2rp/models/equipment_model.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 {
|
||||||
|
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<String> _models = [];
|
||||||
|
List<String> _brands = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
|
EquipmentCategory? _selectedCategory;
|
||||||
|
EquipmentStatus? _selectedStatus;
|
||||||
|
String? _selectedModel;
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
EquipmentProvider();
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
||||||
|
List<EquipmentModel> get allEquipment => _equipment;
|
||||||
|
List<String> get models => _models;
|
||||||
|
List<String> get brands => _brands;
|
||||||
|
EquipmentCategory? get selectedCategory => _selectedCategory;
|
||||||
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
|
String? get selectedModel => _selectedModel;
|
||||||
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
|
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
// Si déjà en train de charger, attendre
|
||||||
|
if (_isLoading) {
|
||||||
|
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 équipements via l'API (utilisé par les dialogs et sélection)
|
||||||
|
Future<void> loadEquipments() async {
|
||||||
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_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();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharge en changeant de filtre ou recherche
|
||||||
|
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 {
|
||||||
|
await _dataService.deleteEquipment(equipmentId);
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un équipement
|
||||||
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||||
|
try {
|
||||||
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour un équipement
|
||||||
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||||
|
try {
|
||||||
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les marques
|
||||||
|
Future<void> loadBrands() async {
|
||||||
|
await ensureLoaded();
|
||||||
|
_extractUniqueValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon retourner le statut de base
|
||||||
|
return equipment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,74 +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;
|
PerformanceMonitor.start('EventProvider.getEvents_API');
|
||||||
if (canViewAllEvents) {
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
eventsSnapshot = await _firestore.collection('events').get();
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
} else {
|
PerformanceMonitor.end('EventProvider.getEvents_API');
|
||||||
eventsSnapshot = await _firestore
|
|
||||||
.collection('events')
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
.where('workforce',
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
arrayContains: _firestore.collection('users').doc(userId))
|
|
||||||
.get();
|
// 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 = [];
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
// Parser chaque événement
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
allEvents.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to parse event ${eventData['id']}: $e');
|
||||||
|
failedCount++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseEvents');
|
||||||
|
|
||||||
print('Found ${eventsSnapshot.docs.length} events for user');
|
_events = allEvents;
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
_events = eventsSnapshot.docs.map((doc) {
|
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||||
print('Event data: ${doc.data()}');
|
|
||||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
print('Parsed ${_events.length} events');
|
|
||||||
|
|
||||||
_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;
|
||||||
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,
|
||||||
|
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
||||||
|
|
||||||
|
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
final doc = await _firestore.collection('events').doc(eventId).get();
|
print('[EventProvider] Loading events for month: $monthKey');
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data()!, doc.id);
|
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||||
}
|
final result = await _dataService.getEventsByMonth(
|
||||||
return null;
|
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) {
|
} catch (e) {
|
||||||
print('Error getting event: $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();
|
||||||
|
}
|
||||||
|
} catch (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');
|
||||||
@@ -76,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) {
|
||||||
@@ -91,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');
|
||||||
@@ -103,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||